Implement command-line tags support
[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",
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 ListTags(opts, args):
70   """List the tags on a given object.
71
72   This is a generic implementation that knows how to deal with all
73   three cases of tag objects (cluster, node, instance). The opts
74   argument is expected to contain a tag_type field denoting what
75   object type we work on.
76
77   """
78   kind, name = _ExtractTagsObject(opts, args)
79   op = opcodes.OpGetTags(kind=kind, name=name)
80   result = SubmitOpCode(op)
81   result = list(result)
82   result.sort()
83   for tag in result:
84     print tag
85
86
87 def AddTags(opts, args):
88   """Add tags on a given object.
89
90   This is a generic implementation that knows how to deal with all
91   three cases of tag objects (cluster, node, instance). The opts
92   argument is expected to contain a tag_type field denoting what
93   object type we work on.
94
95   """
96   kind, name = _ExtractTagsObject(opts, args)
97   if not args:
98     raise errors.OpPrereqError("No tags to be added")
99   op = opcodes.OpAddTags(kind=kind, name=name, tags=args)
100   SubmitOpCode(op)
101
102
103 def RemoveTags(opts, args):
104   """Remove tags from 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   if not args:
114     raise errors.OpPrereqError("No tags to be removed")
115   op = opcodes.OpDelTags(kind=kind, name=name, tags=args)
116   SubmitOpCode(op)
117
118
119 DEBUG_OPT = make_option("-d", "--debug", default=False,
120                         action="store_true",
121                         help="Turn debugging on")
122
123 NOHDR_OPT = make_option("--no-headers", default=False,
124                         action="store_true", dest="no_headers",
125                         help="Don't display column headers")
126
127 SEP_OPT = make_option("--separator", default=None,
128                       action="store", dest="separator",
129                       help="Separator between output fields"
130                       " (defaults to one space)")
131
132 USEUNITS_OPT = make_option("--human-readable", default=False,
133                            action="store_true", dest="human_readable",
134                            help="Print sizes in human readable format")
135
136 FIELDS_OPT = make_option("-o", "--output", dest="output", action="store",
137                          type="string", help="Select output fields",
138                          metavar="FIELDS")
139
140 FORCE_OPT = make_option("-f", "--force", dest="force", action="store_true",
141                         default=False, help="Force the operation")
142
143 _LOCK_OPT = make_option("--lock-retries", default=None,
144                         type="int", help=SUPPRESS_HELP)
145
146 def ARGS_FIXED(val):
147   """Macro-like function denoting a fixed number of arguments"""
148   return -val
149
150
151 def ARGS_ATLEAST(val):
152   """Macro-like function denoting a minimum number of arguments"""
153   return val
154
155
156 ARGS_NONE = None
157 ARGS_ONE = ARGS_FIXED(1)
158 ARGS_ANY = ARGS_ATLEAST(0)
159
160
161 def check_unit(option, opt, value):
162   try:
163     return utils.ParseUnit(value)
164   except errors.UnitParseError, err:
165     raise OptionValueError("option %s: %s" % (opt, err))
166
167
168 class CliOption(Option):
169   TYPES = Option.TYPES + ("unit",)
170   TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
171   TYPE_CHECKER["unit"] = check_unit
172
173
174 # optparse.py sets make_option, so we do it for our own option class, too
175 cli_option = CliOption
176
177
178 def _ParseArgs(argv, commands):
179   """Parses the command line and return the function which must be
180   executed together with its arguments
181
182   Arguments:
183     argv: the command line
184
185     commands: dictionary with special contents, see the design doc for
186     cmdline handling
187
188   """
189   if len(argv) == 0:
190     binary = "<command>"
191   else:
192     binary = argv[0].split("/")[-1]
193
194   if len(argv) > 1 and argv[1] == "--version":
195     print "%s (ganeti) %s" % (binary, constants.RELEASE_VERSION)
196     # Quit right away. That way we don't have to care about this special
197     # argument. optparse.py does it the same.
198     sys.exit(0)
199
200   if len(argv) < 2 or argv[1] not in commands.keys():
201     # let's do a nice thing
202     sortedcmds = commands.keys()
203     sortedcmds.sort()
204     print ("Usage: %(bin)s {command} [options...] [argument...]"
205            "\n%(bin)s <command> --help to see details, or"
206            " man %(bin)s\n" % {"bin": binary})
207     # compute the max line length for cmd + usage
208     mlen = max([len(" %s %s" % (cmd, commands[cmd][3])) for cmd in commands])
209     mlen = min(60, mlen) # should not get here...
210     # and format a nice command list
211     print "Commands:"
212     for cmd in sortedcmds:
213       cmdstr = " %s %s" % (cmd, commands[cmd][3])
214       help_text = commands[cmd][4]
215       help_lines = textwrap.wrap(help_text, 79-3-mlen)
216       print "%-*s - %s" % (mlen, cmdstr,
217                                           help_lines.pop(0))
218       for line in help_lines:
219         print "%-*s   %s" % (mlen, "", line)
220     print
221     return None, None, None
222   cmd = argv.pop(1)
223   func, nargs, parser_opts, usage, description = commands[cmd]
224   parser_opts.append(_LOCK_OPT)
225   parser = OptionParser(option_list=parser_opts,
226                         description=description,
227                         formatter=TitledHelpFormatter(),
228                         usage="%%prog %s %s" % (cmd, usage))
229   parser.disable_interspersed_args()
230   options, args = parser.parse_args()
231   if nargs is None:
232     if len(args) != 0:
233       print >> sys.stderr, ("Error: Command %s expects no arguments" % cmd)
234       return None, None, None
235   elif nargs < 0 and len(args) != -nargs:
236     print >> sys.stderr, ("Error: Command %s expects %d argument(s)" %
237                          (cmd, -nargs))
238     return None, None, None
239   elif nargs >= 0 and len(args) < nargs:
240     print >> sys.stderr, ("Error: Command %s expects at least %d argument(s)" %
241                          (cmd, nargs))
242     return None, None, None
243
244   return func, options, args
245
246
247 def AskUser(text, choices=None):
248   """Ask the user a question.
249
250   Args:
251     text - the question to ask.
252
253     choices - list with elements tuples (input_char, return_value,
254     description); if not given, it will default to: [('y', True,
255     'Perform the operation'), ('n', False, 'Do no do the operation')];
256     note that the '?' char is reserved for help
257
258   Returns: one of the return values from the choices list; if input is
259   not possible (i.e. not running with a tty, we return the last entry
260   from the list
261
262   """
263   if choices is None:
264     choices = [('y', True, 'Perform the operation'),
265                ('n', False, 'Do not perform the operation')]
266   if not choices or not isinstance(choices, list):
267     raise errors.ProgrammerError("Invalid choiches argument to AskUser")
268   for entry in choices:
269     if not isinstance(entry, tuple) or len(entry) < 3 or entry[0] == '?':
270       raise errors.ProgrammerError("Invalid choiches element to AskUser")
271
272   answer = choices[-1][1]
273   new_text = []
274   for line in text.splitlines():
275     new_text.append(textwrap.fill(line, 70, replace_whitespace=False))
276   text = "\n".join(new_text)
277   try:
278     f = file("/dev/tty", "r+")
279   except IOError:
280     return answer
281   try:
282     chars = [entry[0] for entry in choices]
283     chars[-1] = "[%s]" % chars[-1]
284     chars.append('?')
285     maps = dict([(entry[0], entry[1]) for entry in choices])
286     while True:
287       f.write(text)
288       f.write('\n')
289       f.write("/".join(chars))
290       f.write(": ")
291       line = f.readline(2).strip().lower()
292       if line in maps:
293         answer = maps[line]
294         break
295       elif line == '?':
296         for entry in choices:
297           f.write(" %s - %s\n" % (entry[0], entry[2]))
298         f.write("\n")
299         continue
300   finally:
301     f.close()
302   return answer
303
304
305 def SubmitOpCode(op):
306   """Function to submit an opcode.
307
308   This is just a simple wrapper over the construction of the processor
309   instance. It should be extended to better handle feedback and
310   interaction functions.
311
312   """
313   proc = mcpu.Processor()
314   return proc.ExecOpCode(op, logger.ToStdout)
315
316
317 def GenericMain(commands, override=None):
318   """Generic main function for all the gnt-* commands.
319
320   Arguments:
321     - commands: a dictionary with a special structure, see the design doc
322                 for command line handling.
323     - override: if not None, we expect a dictionary with keys that will
324                 override command line options; this can be used to pass
325                 options from the scripts to generic functions
326
327   """
328   # save the program name and the entire command line for later logging
329   if sys.argv:
330     binary = os.path.basename(sys.argv[0]) or sys.argv[0]
331     if len(sys.argv) >= 2:
332       binary += " " + sys.argv[1]
333       old_cmdline = " ".join(sys.argv[2:])
334     else:
335       old_cmdline = ""
336   else:
337     binary = "<unknown program>"
338     old_cmdline = ""
339
340   func, options, args = _ParseArgs(sys.argv, commands)
341   if func is None: # parse error
342     return 1
343
344   if override is not None:
345     for key, val in override.iteritems():
346       setattr(options, key, val)
347
348   logger.SetupLogging(debug=options.debug, program=binary)
349
350   try:
351     utils.Lock('cmd', max_retries=options.lock_retries, debug=options.debug)
352   except errors.LockError, err:
353     logger.ToStderr(str(err))
354     return 1
355
356   if old_cmdline:
357     logger.Info("run with arguments '%s'" % old_cmdline)
358   else:
359     logger.Info("run with no arguments")
360
361   try:
362     try:
363       result = func(options, args)
364     except errors.ConfigurationError, err:
365       logger.Error("Corrupt configuration file: %s" % err)
366       logger.ToStderr("Aborting.")
367       result = 2
368     except errors.HooksAbort, err:
369       logger.ToStderr("Failure: hooks execution failed:")
370       for node, script, out in err.args[0]:
371         if out:
372           logger.ToStderr("  node: %s, script: %s, output: %s" %
373                           (node, script, out))
374         else:
375           logger.ToStderr("  node: %s, script: %s (no output)" %
376                           (node, script))
377       result = 1
378     except errors.HooksFailure, err:
379       logger.ToStderr("Failure: hooks general failure: %s" % str(err))
380       result = 1
381     except errors.ResolverError, err:
382       this_host = utils.HostInfo.SysName()
383       if err.args[0] == this_host:
384         msg = "Failure: can't resolve my own hostname ('%s')"
385       else:
386         msg = "Failure: can't resolve hostname '%s'"
387       logger.ToStderr(msg % err.args[0])
388       result = 1
389     except errors.OpPrereqError, err:
390       logger.ToStderr("Failure: prerequisites not met for this"
391                       " operation:\n%s" % str(err))
392       result = 1
393     except errors.OpExecError, err:
394       logger.ToStderr("Failure: command execution error:\n%s" % str(err))
395       result = 1
396   finally:
397     utils.Unlock('cmd')
398     utils.LockCleanup()
399
400   return result
401
402
403 def GenerateTable(headers, fields, separator, data,
404                   numfields=None, unitfields=None):
405   """Prints a table with headers and different fields.
406
407   Args:
408     headers: Dict of header titles or None if no headers should be shown
409     fields: List of fields to show
410     separator: String used to separate fields or None for spaces
411     data: Data to be printed
412     numfields: List of fields to be aligned to right
413     unitfields: List of fields to be formatted as units
414
415   """
416   if numfields is None:
417     numfields = []
418   if unitfields is None:
419     unitfields = []
420
421   format_fields = []
422   for field in fields:
423     if separator is not None:
424       format_fields.append("%s")
425     elif field in numfields:
426       format_fields.append("%*s")
427     else:
428       format_fields.append("%-*s")
429
430   if separator is None:
431     mlens = [0 for name in fields]
432     format = ' '.join(format_fields)
433   else:
434     format = separator.replace("%", "%%").join(format_fields)
435
436   for row in data:
437     for idx, val in enumerate(row):
438       if fields[idx] in unitfields:
439         try:
440           val = int(val)
441         except ValueError:
442           pass
443         else:
444           val = row[idx] = utils.FormatUnit(val)
445       if separator is None:
446         mlens[idx] = max(mlens[idx], len(val))
447
448   result = []
449   if headers:
450     args = []
451     for idx, name in enumerate(fields):
452       hdr = headers[name]
453       if separator is None:
454         mlens[idx] = max(mlens[idx], len(hdr))
455         args.append(mlens[idx])
456       args.append(hdr)
457     result.append(format % tuple(args))
458
459   for line in data:
460     args = []
461     for idx in xrange(len(fields)):
462       if separator is None:
463         args.append(mlens[idx])
464       args.append(line[idx])
465     result.append(format % tuple(args))
466
467   return result