Revert "CLI: remove command opts/args in "gnt-X""
[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 import time
30 from cStringIO import StringIO
31
32 from ganeti import utils
33 from ganeti import logger
34 from ganeti import errors
35 from ganeti import mcpu
36 from ganeti import constants
37 from ganeti import opcodes
38 from ganeti import luxi
39
40 from optparse import (OptionParser, make_option, TitledHelpFormatter,
41                       Option, OptionValueError, SUPPRESS_HELP)
42
43 __all__ = ["DEBUG_OPT", "NOHDR_OPT", "SEP_OPT", "GenericMain",
44            "SubmitOpCode", "SubmitJob", "SubmitQuery",
45            "cli_option", "GenerateTable", "AskUser",
46            "ARGS_NONE", "ARGS_FIXED", "ARGS_ATLEAST", "ARGS_ANY", "ARGS_ONE",
47            "USEUNITS_OPT", "FIELDS_OPT", "FORCE_OPT",
48            "ListTags", "AddTags", "RemoveTags", "TAG_SRC_OPT",
49            "FormatError", "SplitNodeOption"
50            ]
51
52
53 def _ExtractTagsObject(opts, args):
54   """Extract the tag type object.
55
56   Note that this function will modify its args parameter.
57
58   """
59   if not hasattr(opts, "tag_type"):
60     raise errors.ProgrammerError("tag_type not passed to _ExtractTagsObject")
61   kind = opts.tag_type
62   if kind == constants.TAG_CLUSTER:
63     retval = kind, kind
64   elif kind == constants.TAG_NODE or kind == constants.TAG_INSTANCE:
65     if not args:
66       raise errors.OpPrereqError("no arguments passed to the command")
67     name = args.pop(0)
68     retval = kind, name
69   else:
70     raise errors.ProgrammerError("Unhandled tag type '%s'" % kind)
71   return retval
72
73
74 def _ExtendTags(opts, args):
75   """Extend the args if a source file has been given.
76
77   This function will extend the tags with the contents of the file
78   passed in the 'tags_source' attribute of the opts parameter. A file
79   named '-' will be replaced by stdin.
80
81   """
82   fname = opts.tags_source
83   if fname is None:
84     return
85   if fname == "-":
86     new_fh = sys.stdin
87   else:
88     new_fh = open(fname, "r")
89   new_data = []
90   try:
91     # we don't use the nice 'new_data = [line.strip() for line in fh]'
92     # because of python bug 1633941
93     while True:
94       line = new_fh.readline()
95       if not line:
96         break
97       new_data.append(line.strip())
98   finally:
99     new_fh.close()
100   args.extend(new_data)
101
102
103 def ListTags(opts, args):
104   """List the tags on a given object.
105
106   This is a generic implementation that knows how to deal with all
107   three cases of tag objects (cluster, node, instance). The opts
108   argument is expected to contain a tag_type field denoting what
109   object type we work on.
110
111   """
112   kind, name = _ExtractTagsObject(opts, args)
113   op = opcodes.OpGetTags(kind=kind, name=name)
114   result = SubmitOpCode(op)
115   result = list(result)
116   result.sort()
117   for tag in result:
118     print tag
119
120
121 def AddTags(opts, args):
122   """Add tags on a given object.
123
124   This is a generic implementation that knows how to deal with all
125   three cases of tag objects (cluster, node, instance). The opts
126   argument is expected to contain a tag_type field denoting what
127   object type we work on.
128
129   """
130   kind, name = _ExtractTagsObject(opts, args)
131   _ExtendTags(opts, args)
132   if not args:
133     raise errors.OpPrereqError("No tags to be added")
134   op = opcodes.OpAddTags(kind=kind, name=name, tags=args)
135   SubmitOpCode(op)
136
137
138 def RemoveTags(opts, args):
139   """Remove tags from a given object.
140
141   This is a generic implementation that knows how to deal with all
142   three cases of tag objects (cluster, node, instance). The opts
143   argument is expected to contain a tag_type field denoting what
144   object type we work on.
145
146   """
147   kind, name = _ExtractTagsObject(opts, args)
148   _ExtendTags(opts, args)
149   if not args:
150     raise errors.OpPrereqError("No tags to be removed")
151   op = opcodes.OpDelTags(kind=kind, name=name, tags=args)
152   SubmitOpCode(op)
153
154
155 DEBUG_OPT = make_option("-d", "--debug", default=False,
156                         action="store_true",
157                         help="Turn debugging on")
158
159 NOHDR_OPT = make_option("--no-headers", default=False,
160                         action="store_true", dest="no_headers",
161                         help="Don't display column headers")
162
163 SEP_OPT = make_option("--separator", default=None,
164                       action="store", dest="separator",
165                       help="Separator between output fields"
166                       " (defaults to one space)")
167
168 USEUNITS_OPT = make_option("--human-readable", default=False,
169                            action="store_true", dest="human_readable",
170                            help="Print sizes in human readable format")
171
172 FIELDS_OPT = make_option("-o", "--output", dest="output", action="store",
173                          type="string", help="Comma separated list of"
174                          " output fields",
175                          metavar="FIELDS")
176
177 FORCE_OPT = make_option("-f", "--force", dest="force", action="store_true",
178                         default=False, help="Force the operation")
179
180 TAG_SRC_OPT = make_option("--from", dest="tags_source",
181                           default=None, help="File with tag names")
182
183
184 def ARGS_FIXED(val):
185   """Macro-like function denoting a fixed number of arguments"""
186   return -val
187
188
189 def ARGS_ATLEAST(val):
190   """Macro-like function denoting a minimum number of arguments"""
191   return val
192
193
194 ARGS_NONE = None
195 ARGS_ONE = ARGS_FIXED(1)
196 ARGS_ANY = ARGS_ATLEAST(0)
197
198
199 def check_unit(option, opt, value):
200   """OptParsers custom converter for units.
201
202   """
203   try:
204     return utils.ParseUnit(value)
205   except errors.UnitParseError, err:
206     raise OptionValueError("option %s: %s" % (opt, err))
207
208
209 class CliOption(Option):
210   """Custom option class for optparse.
211
212   """
213   TYPES = Option.TYPES + ("unit",)
214   TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
215   TYPE_CHECKER["unit"] = check_unit
216
217
218 # optparse.py sets make_option, so we do it for our own option class, too
219 cli_option = CliOption
220
221
222 def _ParseArgs(argv, commands, aliases):
223   """Parses the command line and return the function which must be
224   executed together with its arguments
225
226   Arguments:
227     argv: the command line
228
229     commands: dictionary with special contents, see the design doc for
230     cmdline handling
231     aliases: dictionary with command aliases {'alias': 'target, ...}
232
233   """
234   if len(argv) == 0:
235     binary = "<command>"
236   else:
237     binary = argv[0].split("/")[-1]
238
239   if len(argv) > 1 and argv[1] == "--version":
240     print "%s (ganeti) %s" % (binary, constants.RELEASE_VERSION)
241     # Quit right away. That way we don't have to care about this special
242     # argument. optparse.py does it the same.
243     sys.exit(0)
244
245   if len(argv) < 2 or not (argv[1] in commands or
246                            argv[1] in aliases):
247     # let's do a nice thing
248     sortedcmds = commands.keys()
249     sortedcmds.sort()
250     print ("Usage: %(bin)s {command} [options...] [argument...]"
251            "\n%(bin)s <command> --help to see details, or"
252            " man %(bin)s\n" % {"bin": binary})
253     # compute the max line length for cmd + usage
254     mlen = max([len(" %s %s" % (cmd, commands[cmd][3])) for cmd in commands])
255     mlen = min(60, mlen) # should not get here...
256     # and format a nice command list
257     print "Commands:"
258     for cmd in sortedcmds:
259       cmdstr = " %s %s" % (cmd, commands[cmd][3])
260       help_text = commands[cmd][4]
261       help_lines = textwrap.wrap(help_text, 79-3-mlen)
262       print "%-*s - %s" % (mlen, cmdstr,
263                                           help_lines.pop(0))
264       for line in help_lines:
265         print "%-*s   %s" % (mlen, "", line)
266     print
267     return None, None, None
268
269   # get command, unalias it, and look it up in commands
270   cmd = argv.pop(1)
271   if cmd in aliases:
272     if cmd in commands:
273       raise errors.ProgrammerError("Alias '%s' overrides an existing"
274                                    " command" % cmd)
275
276     if aliases[cmd] not in commands:
277       raise errors.ProgrammerError("Alias '%s' maps to non-existing"
278                                    " command '%s'" % (cmd, aliases[cmd]))
279
280     cmd = aliases[cmd]
281
282   func, nargs, parser_opts, usage, description = commands[cmd]
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   cl = luxi.Client()
382   job = opcodes.Job(op_list=[op])
383   jid = SubmitJob(job)
384
385   query = {
386     "object": "jobs",
387     "fields": ["status"],
388     "names": [jid],
389     }
390
391   while True:
392     jdata = SubmitQuery(query)
393     if not jdata:
394       # job not found, go away!
395       raise errors.JobLost("Job with id %s lost" % jid)
396
397     status = jdata[0][0]
398     if status in (opcodes.Job.STATUS_SUCCESS, opcodes.Job.STATUS_FAIL):
399       break
400     time.sleep(1)
401
402   query["fields"].extend(["op_list", "op_status", "op_result"])
403   jdata = SubmitQuery(query)
404   if not jdata:
405     raise errors.JobLost("Job with id %s lost" % jid)
406   status, op_list, op_status, op_result = jdata[0]
407   if status != opcodes.Job.STATUS_SUCCESS:
408     raise errors.OpExecError(op_result[0])
409   return op_result[0]
410
411   if feedback_fn is None:
412     feedback_fn = logger.ToStdout
413   if proc is None:
414     proc = mcpu.Processor(feedback=feedback_fn)
415   return proc.ExecOpCode(op)
416
417
418 def SubmitJob(job, cl=None):
419   if cl is None:
420     cl = luxi.Client()
421   return cl.SubmitJob(job)
422
423
424 def SubmitQuery(data, cl=None):
425   if cl is None:
426     cl = luxi.Client()
427   return cl.Query(data)
428
429
430 def FormatError(err):
431   """Return a formatted error message for a given error.
432
433   This function takes an exception instance and returns a tuple
434   consisting of two values: first, the recommended exit code, and
435   second, a string describing the error message (not
436   newline-terminated).
437
438   """
439   retcode = 1
440   obuf = StringIO()
441   msg = str(err)
442   if isinstance(err, errors.ConfigurationError):
443     txt = "Corrupt configuration file: %s" % msg
444     logger.Error(txt)
445     obuf.write(txt + "\n")
446     obuf.write("Aborting.")
447     retcode = 2
448   elif isinstance(err, errors.HooksAbort):
449     obuf.write("Failure: hooks execution failed:\n")
450     for node, script, out in err.args[0]:
451       if out:
452         obuf.write("  node: %s, script: %s, output: %s\n" %
453                    (node, script, out))
454       else:
455         obuf.write("  node: %s, script: %s (no output)\n" %
456                    (node, script))
457   elif isinstance(err, errors.HooksFailure):
458     obuf.write("Failure: hooks general failure: %s" % msg)
459   elif isinstance(err, errors.ResolverError):
460     this_host = utils.HostInfo.SysName()
461     if err.args[0] == this_host:
462       msg = "Failure: can't resolve my own hostname ('%s')"
463     else:
464       msg = "Failure: can't resolve hostname '%s'"
465     obuf.write(msg % err.args[0])
466   elif isinstance(err, errors.OpPrereqError):
467     obuf.write("Failure: prerequisites not met for this"
468                " operation:\n%s" % msg)
469   elif isinstance(err, errors.OpExecError):
470     obuf.write("Failure: command execution error:\n%s" % msg)
471   elif isinstance(err, errors.TagError):
472     obuf.write("Failure: invalid tag(s) given:\n%s" % msg)
473   elif isinstance(err, errors.GenericError):
474     obuf.write("Unhandled Ganeti error: %s" % msg)
475   else:
476     obuf.write("Unhandled exception: %s" % msg)
477   return retcode, obuf.getvalue().rstrip('\n')
478
479
480 def GenericMain(commands, override=None, aliases=None):
481   """Generic main function for all the gnt-* commands.
482
483   Arguments:
484     - commands: a dictionary with a special structure, see the design doc
485                 for command line handling.
486     - override: if not None, we expect a dictionary with keys that will
487                 override command line options; this can be used to pass
488                 options from the scripts to generic functions
489     - aliases: dictionary with command aliases {'alias': 'target, ...}
490
491   """
492   # save the program name and the entire command line for later logging
493   if sys.argv:
494     binary = os.path.basename(sys.argv[0]) or sys.argv[0]
495     if len(sys.argv) >= 2:
496       binary += " " + sys.argv[1]
497       old_cmdline = " ".join(sys.argv[2:])
498     else:
499       old_cmdline = ""
500   else:
501     binary = "<unknown program>"
502     old_cmdline = ""
503
504   if aliases is None:
505     aliases = {}
506
507   func, options, args = _ParseArgs(sys.argv, commands, aliases)
508   if func is None: # parse error
509     return 1
510
511   if override is not None:
512     for key, val in override.iteritems():
513       setattr(options, key, val)
514
515   logger.SetupLogging(debug=options.debug, program=binary)
516
517   utils.debug = options.debug
518
519   if old_cmdline:
520     logger.Info("run with arguments '%s'" % old_cmdline)
521   else:
522     logger.Info("run with no arguments")
523
524   try:
525     result = func(options, args)
526   except errors.GenericError, err:
527     result, err_msg = FormatError(err)
528     logger.ToStderr(err_msg)
529
530   return result
531
532
533 def GenerateTable(headers, fields, separator, data,
534                   numfields=None, unitfields=None):
535   """Prints a table with headers and different fields.
536
537   Args:
538     headers: Dict of header titles or None if no headers should be shown
539     fields: List of fields to show
540     separator: String used to separate fields or None for spaces
541     data: Data to be printed
542     numfields: List of fields to be aligned to right
543     unitfields: List of fields to be formatted as units
544
545   """
546   if numfields is None:
547     numfields = []
548   if unitfields is None:
549     unitfields = []
550
551   format_fields = []
552   for field in fields:
553     if headers and field not in headers:
554       raise errors.ProgrammerError("Missing header description for field '%s'"
555                                    % field)
556     if separator is not None:
557       format_fields.append("%s")
558     elif field in numfields:
559       format_fields.append("%*s")
560     else:
561       format_fields.append("%-*s")
562
563   if separator is None:
564     mlens = [0 for name in fields]
565     format = ' '.join(format_fields)
566   else:
567     format = separator.replace("%", "%%").join(format_fields)
568
569   for row in data:
570     for idx, val in enumerate(row):
571       if fields[idx] in unitfields:
572         try:
573           val = int(val)
574         except ValueError:
575           pass
576         else:
577           val = row[idx] = utils.FormatUnit(val)
578       val = row[idx] = str(val)
579       if separator is None:
580         mlens[idx] = max(mlens[idx], len(val))
581
582   result = []
583   if headers:
584     args = []
585     for idx, name in enumerate(fields):
586       hdr = headers[name]
587       if separator is None:
588         mlens[idx] = max(mlens[idx], len(hdr))
589         args.append(mlens[idx])
590       args.append(hdr)
591     result.append(format % tuple(args))
592
593   for line in data:
594     args = []
595     for idx in xrange(len(fields)):
596       if separator is None:
597         args.append(mlens[idx])
598       args.append(line[idx])
599     result.append(format % tuple(args))
600
601   return result