Split cli.SubmitOpCode in two parts
[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 constants
36 from ganeti import opcodes
37 from ganeti import luxi
38 from ganeti import ssconf
39
40 from optparse import (OptionParser, make_option, TitledHelpFormatter,
41                       Option, OptionValueError)
42
43 __all__ = ["DEBUG_OPT", "NOHDR_OPT", "SEP_OPT", "GenericMain",
44            "SubmitOpCode", "GetClient",
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" % cmd) 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" % (cmd,)
260       help_text = commands[cmd][4]
261       help_lines = textwrap.wrap(help_text, 79-3-mlen)
262       print "%-*s - %s" % (mlen, cmdstr, 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 = OptionParser(option_list=parser_opts,
283                         description=description,
284                         formatter=TitledHelpFormatter(),
285                         usage="%%prog %s %s" % (cmd, usage))
286   parser.disable_interspersed_args()
287   options, args = parser.parse_args()
288   if nargs is None:
289     if len(args) != 0:
290       print >> sys.stderr, ("Error: Command %s expects no arguments" % cmd)
291       return None, None, None
292   elif nargs < 0 and len(args) != -nargs:
293     print >> sys.stderr, ("Error: Command %s expects %d argument(s)" %
294                          (cmd, -nargs))
295     return None, None, None
296   elif nargs >= 0 and len(args) < nargs:
297     print >> sys.stderr, ("Error: Command %s expects at least %d argument(s)" %
298                          (cmd, nargs))
299     return None, None, None
300
301   return func, options, args
302
303
304 def SplitNodeOption(value):
305   """Splits the value of a --node option.
306
307   """
308   if value and ':' in value:
309     return value.split(':', 1)
310   else:
311     return (value, None)
312
313
314 def AskUser(text, choices=None):
315   """Ask the user a question.
316
317   Args:
318     text - the question to ask.
319
320     choices - list with elements tuples (input_char, return_value,
321     description); if not given, it will default to: [('y', True,
322     'Perform the operation'), ('n', False, 'Do no do the operation')];
323     note that the '?' char is reserved for help
324
325   Returns: one of the return values from the choices list; if input is
326   not possible (i.e. not running with a tty, we return the last entry
327   from the list
328
329   """
330   if choices is None:
331     choices = [('y', True, 'Perform the operation'),
332                ('n', False, 'Do not perform the operation')]
333   if not choices or not isinstance(choices, list):
334     raise errors.ProgrammerError("Invalid choiches argument to AskUser")
335   for entry in choices:
336     if not isinstance(entry, tuple) or len(entry) < 3 or entry[0] == '?':
337       raise errors.ProgrammerError("Invalid choiches element to AskUser")
338
339   answer = choices[-1][1]
340   new_text = []
341   for line in text.splitlines():
342     new_text.append(textwrap.fill(line, 70, replace_whitespace=False))
343   text = "\n".join(new_text)
344   try:
345     f = file("/dev/tty", "a+")
346   except IOError:
347     return answer
348   try:
349     chars = [entry[0] for entry in choices]
350     chars[-1] = "[%s]" % chars[-1]
351     chars.append('?')
352     maps = dict([(entry[0], entry[1]) for entry in choices])
353     while True:
354       f.write(text)
355       f.write('\n')
356       f.write("/".join(chars))
357       f.write(": ")
358       line = f.readline(2).strip().lower()
359       if line in maps:
360         answer = maps[line]
361         break
362       elif line == '?':
363         for entry in choices:
364           f.write(" %s - %s\n" % (entry[0], entry[2]))
365         f.write("\n")
366         continue
367   finally:
368     f.close()
369   return answer
370
371
372 def SendJob(ops, cl=None):
373   """Function to submit an opcode without waiting for the results.
374
375   @type ops: list
376   @param ops: list of opcodes
377   @type cl: luxi.Client
378   @param cl: the luxi client to use for communicating with the master;
379              if None, a new client will be created
380
381   """
382   if cl is None:
383     cl = GetClient()
384
385   job_id = cl.SubmitJob(ops)
386
387   return job_id
388
389
390 def PollJob(job_id, cl=None):
391   """Function to poll for the result of a job.
392
393   @type job_id: job identified
394   @param job_id: the job to poll for results
395   @type cl: luxi.Client
396   @param cl: the luxi client to use for communicating with the master;
397              if None, a new client will be created
398
399   """
400   if cl is None:
401     cl = GetClient()
402
403   lastmsg = None
404   while True:
405     jobs = cl.QueryJobs([job_id], ["status", "ticker"])
406     if not jobs:
407       # job not found, go away!
408       raise errors.JobLost("Job with id %s lost" % job_id)
409
410     # TODO: Handle canceled and archived jobs
411     status = jobs[0][0]
412     if status in (constants.JOB_STATUS_SUCCESS, constants.JOB_STATUS_ERROR):
413       break
414     msg = jobs[0][1]
415     if msg is not None and msg != lastmsg:
416       if callable(feedback_fn):
417         feedback_fn(msg)
418       else:
419         print "%s %s" % (time.ctime(msg[0]), msg[2])
420     lastmsg = msg
421     time.sleep(1)
422
423   jobs = cl.QueryJobs([job_id], ["status", "opresult"])
424   if not jobs:
425     raise errors.JobLost("Job with id %s lost" % job_id)
426
427   status, result = jobs[0]
428   if status == constants.JOB_STATUS_SUCCESS:
429     return result[0]
430   else:
431     raise errors.OpExecError(result)
432
433
434 def SubmitOpCode(op, cl=None, feedback_fn=None):
435   """Legacy function to submit an opcode.
436
437   This is just a simple wrapper over the construction of the processor
438   instance. It should be extended to better handle feedback and
439   interaction functions.
440
441   """
442   if cl is None:
443     cl = GetClient()
444
445   job_id = SendJob([op], cl)
446
447   return PollJob(job_id, cl)
448
449
450 def GetClient():
451   # TODO: Cache object?
452   try:
453     client = luxi.Client()
454   except luxi.NoMasterError:
455     master, myself = ssconf.GetMasterAndMyself()
456     if master != myself:
457       raise errors.OpPrereqError("This is not the master node, please connect"
458                                  " to node '%s' and rerun the command" %
459                                  master)
460     else:
461       raise
462   return client
463
464
465 def FormatError(err):
466   """Return a formatted error message for a given error.
467
468   This function takes an exception instance and returns a tuple
469   consisting of two values: first, the recommended exit code, and
470   second, a string describing the error message (not
471   newline-terminated).
472
473   """
474   retcode = 1
475   obuf = StringIO()
476   msg = str(err)
477   if isinstance(err, errors.ConfigurationError):
478     txt = "Corrupt configuration file: %s" % msg
479     logger.Error(txt)
480     obuf.write(txt + "\n")
481     obuf.write("Aborting.")
482     retcode = 2
483   elif isinstance(err, errors.HooksAbort):
484     obuf.write("Failure: hooks execution failed:\n")
485     for node, script, out in err.args[0]:
486       if out:
487         obuf.write("  node: %s, script: %s, output: %s\n" %
488                    (node, script, out))
489       else:
490         obuf.write("  node: %s, script: %s (no output)\n" %
491                    (node, script))
492   elif isinstance(err, errors.HooksFailure):
493     obuf.write("Failure: hooks general failure: %s" % msg)
494   elif isinstance(err, errors.ResolverError):
495     this_host = utils.HostInfo.SysName()
496     if err.args[0] == this_host:
497       msg = "Failure: can't resolve my own hostname ('%s')"
498     else:
499       msg = "Failure: can't resolve hostname '%s'"
500     obuf.write(msg % err.args[0])
501   elif isinstance(err, errors.OpPrereqError):
502     obuf.write("Failure: prerequisites not met for this"
503                " operation:\n%s" % msg)
504   elif isinstance(err, errors.OpExecError):
505     obuf.write("Failure: command execution error:\n%s" % msg)
506   elif isinstance(err, errors.TagError):
507     obuf.write("Failure: invalid tag(s) given:\n%s" % msg)
508   elif isinstance(err, errors.GenericError):
509     obuf.write("Unhandled Ganeti error: %s" % msg)
510   elif isinstance(err, luxi.NoMasterError):
511     obuf.write("Cannot communicate with the master daemon.\nIs it running"
512                " and listening on '%s'?" % err.args[0])
513   elif isinstance(err, luxi.TimeoutError):
514     obuf.write("Timeout while talking to the master daemon. Error:\n"
515                "%s" % msg)
516   elif isinstance(err, luxi.ProtocolError):
517     obuf.write("Unhandled protocol error while talking to the master daemon:\n"
518                "%s" % msg)
519   else:
520     obuf.write("Unhandled exception: %s" % msg)
521   return retcode, obuf.getvalue().rstrip('\n')
522
523
524 def GenericMain(commands, override=None, aliases=None):
525   """Generic main function for all the gnt-* commands.
526
527   Arguments:
528     - commands: a dictionary with a special structure, see the design doc
529                 for command line handling.
530     - override: if not None, we expect a dictionary with keys that will
531                 override command line options; this can be used to pass
532                 options from the scripts to generic functions
533     - aliases: dictionary with command aliases {'alias': 'target, ...}
534
535   """
536   # save the program name and the entire command line for later logging
537   if sys.argv:
538     binary = os.path.basename(sys.argv[0]) or sys.argv[0]
539     if len(sys.argv) >= 2:
540       binary += " " + sys.argv[1]
541       old_cmdline = " ".join(sys.argv[2:])
542     else:
543       old_cmdline = ""
544   else:
545     binary = "<unknown program>"
546     old_cmdline = ""
547
548   if aliases is None:
549     aliases = {}
550
551   func, options, args = _ParseArgs(sys.argv, commands, aliases)
552   if func is None: # parse error
553     return 1
554
555   if override is not None:
556     for key, val in override.iteritems():
557       setattr(options, key, val)
558
559   logger.SetupLogging(constants.LOG_COMMANDS, debug=options.debug,
560                       stderr_logging=True, program=binary)
561
562   utils.debug = options.debug
563
564   if old_cmdline:
565     logger.Info("run with arguments '%s'" % old_cmdline)
566   else:
567     logger.Info("run with no arguments")
568
569   try:
570     result = func(options, args)
571   except (errors.GenericError, luxi.ProtocolError), err:
572     result, err_msg = FormatError(err)
573     logger.ToStderr(err_msg)
574
575   return result
576
577
578 def GenerateTable(headers, fields, separator, data,
579                   numfields=None, unitfields=None):
580   """Prints a table with headers and different fields.
581
582   Args:
583     headers: Dict of header titles or None if no headers should be shown
584     fields: List of fields to show
585     separator: String used to separate fields or None for spaces
586     data: Data to be printed
587     numfields: List of fields to be aligned to right
588     unitfields: List of fields to be formatted as units
589
590   """
591   if numfields is None:
592     numfields = []
593   if unitfields is None:
594     unitfields = []
595
596   format_fields = []
597   for field in fields:
598     if headers and field not in headers:
599       raise errors.ProgrammerError("Missing header description for field '%s'"
600                                    % field)
601     if separator is not None:
602       format_fields.append("%s")
603     elif field in numfields:
604       format_fields.append("%*s")
605     else:
606       format_fields.append("%-*s")
607
608   if separator is None:
609     mlens = [0 for name in fields]
610     format = ' '.join(format_fields)
611   else:
612     format = separator.replace("%", "%%").join(format_fields)
613
614   for row in data:
615     for idx, val in enumerate(row):
616       if fields[idx] in unitfields:
617         try:
618           val = int(val)
619         except ValueError:
620           pass
621         else:
622           val = row[idx] = utils.FormatUnit(val)
623       val = row[idx] = str(val)
624       if separator is None:
625         mlens[idx] = max(mlens[idx], len(val))
626
627   result = []
628   if headers:
629     args = []
630     for idx, name in enumerate(fields):
631       hdr = headers[name]
632       if separator is None:
633         mlens[idx] = max(mlens[idx], len(hdr))
634         args.append(mlens[idx])
635       args.append(hdr)
636     result.append(format % tuple(args))
637
638   for line in data:
639     args = []
640     for idx in xrange(len(fields)):
641       if separator is None:
642         args.append(mlens[idx])
643       args.append(line[idx])
644     result.append(format % tuple(args))
645
646   return result