Enhance cli.SubmitOpcode to use custom parameters
[ganeti-local] / lib / cli.py
1 #!/usr/bin/python
2 #
3
4 # Copyright (C) 2006, 2007 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21
22 """Module dealing with command line parsing"""
23
24
25 import sys
26 import textwrap
27 import os.path
28 import copy
29
30 from ganeti import utils
31 from ganeti import logger
32 from ganeti import errors
33 from ganeti import mcpu
34 from ganeti import constants
35 from ganeti import opcodes
36
37 from optparse import (OptionParser, make_option, TitledHelpFormatter,
38                       Option, OptionValueError, SUPPRESS_HELP)
39
40 __all__ = ["DEBUG_OPT", "NOHDR_OPT", "SEP_OPT", "GenericMain", "SubmitOpCode",
41            "cli_option", "GenerateTable", "AskUser",
42            "ARGS_NONE", "ARGS_FIXED", "ARGS_ATLEAST", "ARGS_ANY", "ARGS_ONE",
43            "USEUNITS_OPT", "FIELDS_OPT", "FORCE_OPT",
44            "ListTags", "AddTags", "RemoveTags", "TAG_SRC_OPT",
45            ]
46
47
48 def _ExtractTagsObject(opts, args):
49   """Extract the tag type object.
50
51   Note that this function will modify its args parameter.
52
53   """
54   if not hasattr(opts, "tag_type"):
55     raise errors.ProgrammerError("tag_type not passed to _ExtractTagsObject")
56   kind = opts.tag_type
57   if kind == constants.TAG_CLUSTER:
58     retval = kind, kind
59   elif kind == constants.TAG_NODE or kind == constants.TAG_INSTANCE:
60     if not args:
61       raise errors.OpPrereq("no arguments passed to the command")
62     name = args.pop(0)
63     retval = kind, name
64   else:
65     raise errors.ProgrammerError("Unhandled tag type '%s'" % kind)
66   return retval
67
68
69 def _ExtendTags(opts, args):
70   """Extend the args if a source file has been given.
71
72   This function will extend the tags with the contents of the file
73   passed in the 'tags_source' attribute of the opts parameter. A file
74   named '-' will be replaced by stdin.
75
76   """
77   fname = opts.tags_source
78   if fname is None:
79     return
80   if fname == "-":
81     new_fh = sys.stdin
82   else:
83     new_fh = open(fname, "r")
84   new_data = []
85   try:
86     # we don't use the nice 'new_data = [line.strip() for line in fh]'
87     # because of python bug 1633941
88     while True:
89       line = new_fh.readline()
90       if not line:
91         break
92       new_data.append(line.strip())
93   finally:
94     new_fh.close()
95   args.extend(new_data)
96
97
98 def ListTags(opts, args):
99   """List the tags on a given object.
100
101   This is a generic implementation that knows how to deal with all
102   three cases of tag objects (cluster, node, instance). The opts
103   argument is expected to contain a tag_type field denoting what
104   object type we work on.
105
106   """
107   kind, name = _ExtractTagsObject(opts, args)
108   op = opcodes.OpGetTags(kind=kind, name=name)
109   result = SubmitOpCode(op)
110   result = list(result)
111   result.sort()
112   for tag in result:
113     print tag
114
115
116 def AddTags(opts, args):
117   """Add tags on a given object.
118
119   This is a generic implementation that knows how to deal with all
120   three cases of tag objects (cluster, node, instance). The opts
121   argument is expected to contain a tag_type field denoting what
122   object type we work on.
123
124   """
125   kind, name = _ExtractTagsObject(opts, args)
126   _ExtendTags(opts, args)
127   if not args:
128     raise errors.OpPrereqError("No tags to be added")
129   op = opcodes.OpAddTags(kind=kind, name=name, tags=args)
130   SubmitOpCode(op)
131
132
133 def RemoveTags(opts, args):
134   """Remove tags from a given object.
135
136   This is a generic implementation that knows how to deal with all
137   three cases of tag objects (cluster, node, instance). The opts
138   argument is expected to contain a tag_type field denoting what
139   object type we work on.
140
141   """
142   kind, name = _ExtractTagsObject(opts, args)
143   _ExtendTags(opts, args)
144   if not args:
145     raise errors.OpPrereqError("No tags to be removed")
146   op = opcodes.OpDelTags(kind=kind, name=name, tags=args)
147   SubmitOpCode(op)
148
149
150 DEBUG_OPT = make_option("-d", "--debug", default=False,
151                         action="store_true",
152                         help="Turn debugging on")
153
154 NOHDR_OPT = make_option("--no-headers", default=False,
155                         action="store_true", dest="no_headers",
156                         help="Don't display column headers")
157
158 SEP_OPT = make_option("--separator", default=None,
159                       action="store", dest="separator",
160                       help="Separator between output fields"
161                       " (defaults to one space)")
162
163 USEUNITS_OPT = make_option("--human-readable", default=False,
164                            action="store_true", dest="human_readable",
165                            help="Print sizes in human readable format")
166
167 FIELDS_OPT = make_option("-o", "--output", dest="output", action="store",
168                          type="string", help="Select output fields",
169                          metavar="FIELDS")
170
171 FORCE_OPT = make_option("-f", "--force", dest="force", action="store_true",
172                         default=False, help="Force the operation")
173
174 _LOCK_OPT = make_option("--lock-retries", default=None,
175                         type="int", help=SUPPRESS_HELP)
176
177 TAG_SRC_OPT = make_option("--from", dest="tags_source",
178                           default=None, help="File with tag names")
179
180 def ARGS_FIXED(val):
181   """Macro-like function denoting a fixed number of arguments"""
182   return -val
183
184
185 def ARGS_ATLEAST(val):
186   """Macro-like function denoting a minimum number of arguments"""
187   return val
188
189
190 ARGS_NONE = None
191 ARGS_ONE = ARGS_FIXED(1)
192 ARGS_ANY = ARGS_ATLEAST(0)
193
194
195 def check_unit(option, opt, value):
196   try:
197     return utils.ParseUnit(value)
198   except errors.UnitParseError, err:
199     raise OptionValueError("option %s: %s" % (opt, err))
200
201
202 class CliOption(Option):
203   TYPES = Option.TYPES + ("unit",)
204   TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
205   TYPE_CHECKER["unit"] = check_unit
206
207
208 # optparse.py sets make_option, so we do it for our own option class, too
209 cli_option = CliOption
210
211
212 def _ParseArgs(argv, commands):
213   """Parses the command line and return the function which must be
214   executed together with its arguments
215
216   Arguments:
217     argv: the command line
218
219     commands: dictionary with special contents, see the design doc for
220     cmdline handling
221
222   """
223   if len(argv) == 0:
224     binary = "<command>"
225   else:
226     binary = argv[0].split("/")[-1]
227
228   if len(argv) > 1 and argv[1] == "--version":
229     print "%s (ganeti) %s" % (binary, constants.RELEASE_VERSION)
230     # Quit right away. That way we don't have to care about this special
231     # argument. optparse.py does it the same.
232     sys.exit(0)
233
234   if len(argv) < 2 or argv[1] not in commands.keys():
235     # let's do a nice thing
236     sortedcmds = commands.keys()
237     sortedcmds.sort()
238     print ("Usage: %(bin)s {command} [options...] [argument...]"
239            "\n%(bin)s <command> --help to see details, or"
240            " man %(bin)s\n" % {"bin": binary})
241     # compute the max line length for cmd + usage
242     mlen = max([len(" %s %s" % (cmd, commands[cmd][3])) for cmd in commands])
243     mlen = min(60, mlen) # should not get here...
244     # and format a nice command list
245     print "Commands:"
246     for cmd in sortedcmds:
247       cmdstr = " %s %s" % (cmd, commands[cmd][3])
248       help_text = commands[cmd][4]
249       help_lines = textwrap.wrap(help_text, 79-3-mlen)
250       print "%-*s - %s" % (mlen, cmdstr,
251                                           help_lines.pop(0))
252       for line in help_lines:
253         print "%-*s   %s" % (mlen, "", line)
254     print
255     return None, None, None
256   cmd = argv.pop(1)
257   func, nargs, parser_opts, usage, description = commands[cmd]
258   parser_opts.append(_LOCK_OPT)
259   parser = OptionParser(option_list=parser_opts,
260                         description=description,
261                         formatter=TitledHelpFormatter(),
262                         usage="%%prog %s %s" % (cmd, usage))
263   parser.disable_interspersed_args()
264   options, args = parser.parse_args()
265   if nargs is None:
266     if len(args) != 0:
267       print >> sys.stderr, ("Error: Command %s expects no arguments" % cmd)
268       return None, None, None
269   elif nargs < 0 and len(args) != -nargs:
270     print >> sys.stderr, ("Error: Command %s expects %d argument(s)" %
271                          (cmd, -nargs))
272     return None, None, None
273   elif nargs >= 0 and len(args) < nargs:
274     print >> sys.stderr, ("Error: Command %s expects at least %d argument(s)" %
275                          (cmd, nargs))
276     return None, None, None
277
278   return func, options, args
279
280
281 def AskUser(text, choices=None):
282   """Ask the user a question.
283
284   Args:
285     text - the question to ask.
286
287     choices - list with elements tuples (input_char, return_value,
288     description); if not given, it will default to: [('y', True,
289     'Perform the operation'), ('n', False, 'Do no do the operation')];
290     note that the '?' char is reserved for help
291
292   Returns: one of the return values from the choices list; if input is
293   not possible (i.e. not running with a tty, we return the last entry
294   from the list
295
296   """
297   if choices is None:
298     choices = [('y', True, 'Perform the operation'),
299                ('n', False, 'Do not perform the operation')]
300   if not choices or not isinstance(choices, list):
301     raise errors.ProgrammerError("Invalid choiches argument to AskUser")
302   for entry in choices:
303     if not isinstance(entry, tuple) or len(entry) < 3 or entry[0] == '?':
304       raise errors.ProgrammerError("Invalid choiches element to AskUser")
305
306   answer = choices[-1][1]
307   new_text = []
308   for line in text.splitlines():
309     new_text.append(textwrap.fill(line, 70, replace_whitespace=False))
310   text = "\n".join(new_text)
311   try:
312     f = file("/dev/tty", "r+")
313   except IOError:
314     return answer
315   try:
316     chars = [entry[0] for entry in choices]
317     chars[-1] = "[%s]" % chars[-1]
318     chars.append('?')
319     maps = dict([(entry[0], entry[1]) for entry in choices])
320     while True:
321       f.write(text)
322       f.write('\n')
323       f.write("/".join(chars))
324       f.write(": ")
325       line = f.readline(2).strip().lower()
326       if line in maps:
327         answer = maps[line]
328         break
329       elif line == '?':
330         for entry in choices:
331           f.write(" %s - %s\n" % (entry[0], entry[2]))
332         f.write("\n")
333         continue
334   finally:
335     f.close()
336   return answer
337
338
339 def SubmitOpCode(op, proc=None, feedback_fn=None):
340   """Function to submit an opcode.
341
342   This is just a simple wrapper over the construction of the processor
343   instance. It should be extended to better handle feedback and
344   interaction functions.
345
346   """
347   if proc is None:
348     proc = mcpu.Processor()
349   if feedback_fn is None:
350     feedback_fn = logger.ToStdout
351   return proc.ExecOpCode(op, feedback_fn)
352
353
354 def GenericMain(commands, override=None):
355   """Generic main function for all the gnt-* commands.
356
357   Arguments:
358     - commands: a dictionary with a special structure, see the design doc
359                 for command line handling.
360     - override: if not None, we expect a dictionary with keys that will
361                 override command line options; this can be used to pass
362                 options from the scripts to generic functions
363
364   """
365   # save the program name and the entire command line for later logging
366   if sys.argv:
367     binary = os.path.basename(sys.argv[0]) or sys.argv[0]
368     if len(sys.argv) >= 2:
369       binary += " " + sys.argv[1]
370       old_cmdline = " ".join(sys.argv[2:])
371     else:
372       old_cmdline = ""
373   else:
374     binary = "<unknown program>"
375     old_cmdline = ""
376
377   func, options, args = _ParseArgs(sys.argv, commands)
378   if func is None: # parse error
379     return 1
380
381   if override is not None:
382     for key, val in override.iteritems():
383       setattr(options, key, val)
384
385   logger.SetupLogging(debug=options.debug, program=binary)
386
387   try:
388     utils.Lock('cmd', max_retries=options.lock_retries, debug=options.debug)
389   except errors.LockError, err:
390     logger.ToStderr(str(err))
391     return 1
392
393   if old_cmdline:
394     logger.Info("run with arguments '%s'" % old_cmdline)
395   else:
396     logger.Info("run with no arguments")
397
398   try:
399     try:
400       result = func(options, args)
401     except errors.ConfigurationError, err:
402       logger.Error("Corrupt configuration file: %s" % err)
403       logger.ToStderr("Aborting.")
404       result = 2
405     except errors.HooksAbort, err:
406       logger.ToStderr("Failure: hooks execution failed:")
407       for node, script, out in err.args[0]:
408         if out:
409           logger.ToStderr("  node: %s, script: %s, output: %s" %
410                           (node, script, out))
411         else:
412           logger.ToStderr("  node: %s, script: %s (no output)" %
413                           (node, script))
414       result = 1
415     except errors.HooksFailure, err:
416       logger.ToStderr("Failure: hooks general failure: %s" % str(err))
417       result = 1
418     except errors.ResolverError, err:
419       this_host = utils.HostInfo.SysName()
420       if err.args[0] == this_host:
421         msg = "Failure: can't resolve my own hostname ('%s')"
422       else:
423         msg = "Failure: can't resolve hostname '%s'"
424       logger.ToStderr(msg % err.args[0])
425       result = 1
426     except errors.OpPrereqError, err:
427       logger.ToStderr("Failure: prerequisites not met for this"
428                       " operation:\n%s" % str(err))
429       result = 1
430     except errors.OpExecError, err:
431       logger.ToStderr("Failure: command execution error:\n%s" % str(err))
432       result = 1
433     except errors.TagError, err:
434       logger.ToStderr("Failure: invalid tag(s) given:\n%s" % str(err))
435       result = 1
436   finally:
437     utils.Unlock('cmd')
438     utils.LockCleanup()
439
440   return result
441
442
443 def GenerateTable(headers, fields, separator, data,
444                   numfields=None, unitfields=None):
445   """Prints a table with headers and different fields.
446
447   Args:
448     headers: Dict of header titles or None if no headers should be shown
449     fields: List of fields to show
450     separator: String used to separate fields or None for spaces
451     data: Data to be printed
452     numfields: List of fields to be aligned to right
453     unitfields: List of fields to be formatted as units
454
455   """
456   if numfields is None:
457     numfields = []
458   if unitfields is None:
459     unitfields = []
460
461   format_fields = []
462   for field in fields:
463     if separator is not None:
464       format_fields.append("%s")
465     elif field in numfields:
466       format_fields.append("%*s")
467     else:
468       format_fields.append("%-*s")
469
470   if separator is None:
471     mlens = [0 for name in fields]
472     format = ' '.join(format_fields)
473   else:
474     format = separator.replace("%", "%%").join(format_fields)
475
476   for row in data:
477     for idx, val in enumerate(row):
478       if fields[idx] in unitfields:
479         try:
480           val = int(val)
481         except ValueError:
482           pass
483         else:
484           val = row[idx] = utils.FormatUnit(val)
485       if separator is None:
486         mlens[idx] = max(mlens[idx], len(val))
487
488   result = []
489   if headers:
490     args = []
491     for idx, name in enumerate(fields):
492       hdr = headers[name]
493       if separator is None:
494         mlens[idx] = max(mlens[idx], len(hdr))
495         args.append(mlens[idx])
496       args.append(hdr)
497     result.append(format % tuple(args))
498
499   for line in data:
500     args = []
501     for idx in xrange(len(fields)):
502       if separator is None:
503         args.append(mlens[idx])
504       args.append(line[idx])
505     result.append(format % tuple(args))
506
507   return result