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