Add a simple timespec parsing function
[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", "SUBMIT_OPT",
48            "ListTags", "AddTags", "RemoveTags", "TAG_SRC_OPT",
49            "FormatError", "SplitNodeOption", "SubmitOrSend",
50            "JobSubmittedException", "FormatTimestamp",
51            ]
52
53
54 def _ExtractTagsObject(opts, args):
55   """Extract the tag type object.
56
57   Note that this function will modify its args parameter.
58
59   """
60   if not hasattr(opts, "tag_type"):
61     raise errors.ProgrammerError("tag_type not passed to _ExtractTagsObject")
62   kind = opts.tag_type
63   if kind == constants.TAG_CLUSTER:
64     retval = kind, kind
65   elif kind == constants.TAG_NODE or kind == constants.TAG_INSTANCE:
66     if not args:
67       raise errors.OpPrereqError("no arguments passed to the command")
68     name = args.pop(0)
69     retval = kind, name
70   else:
71     raise errors.ProgrammerError("Unhandled tag type '%s'" % kind)
72   return retval
73
74
75 def _ExtendTags(opts, args):
76   """Extend the args if a source file has been given.
77
78   This function will extend the tags with the contents of the file
79   passed in the 'tags_source' attribute of the opts parameter. A file
80   named '-' will be replaced by stdin.
81
82   """
83   fname = opts.tags_source
84   if fname is None:
85     return
86   if fname == "-":
87     new_fh = sys.stdin
88   else:
89     new_fh = open(fname, "r")
90   new_data = []
91   try:
92     # we don't use the nice 'new_data = [line.strip() for line in fh]'
93     # because of python bug 1633941
94     while True:
95       line = new_fh.readline()
96       if not line:
97         break
98       new_data.append(line.strip())
99   finally:
100     new_fh.close()
101   args.extend(new_data)
102
103
104 def ListTags(opts, args):
105   """List the tags on a given object.
106
107   This is a generic implementation that knows how to deal with all
108   three cases of tag objects (cluster, node, instance). The opts
109   argument is expected to contain a tag_type field denoting what
110   object type we work on.
111
112   """
113   kind, name = _ExtractTagsObject(opts, args)
114   op = opcodes.OpGetTags(kind=kind, name=name)
115   result = SubmitOpCode(op)
116   result = list(result)
117   result.sort()
118   for tag in result:
119     print tag
120
121
122 def AddTags(opts, args):
123   """Add tags on a given object.
124
125   This is a generic implementation that knows how to deal with all
126   three cases of tag objects (cluster, node, instance). The opts
127   argument is expected to contain a tag_type field denoting what
128   object type we work on.
129
130   """
131   kind, name = _ExtractTagsObject(opts, args)
132   _ExtendTags(opts, args)
133   if not args:
134     raise errors.OpPrereqError("No tags to be added")
135   op = opcodes.OpAddTags(kind=kind, name=name, tags=args)
136   SubmitOpCode(op)
137
138
139 def RemoveTags(opts, args):
140   """Remove tags from a given object.
141
142   This is a generic implementation that knows how to deal with all
143   three cases of tag objects (cluster, node, instance). The opts
144   argument is expected to contain a tag_type field denoting what
145   object type we work on.
146
147   """
148   kind, name = _ExtractTagsObject(opts, args)
149   _ExtendTags(opts, args)
150   if not args:
151     raise errors.OpPrereqError("No tags to be removed")
152   op = opcodes.OpDelTags(kind=kind, name=name, tags=args)
153   SubmitOpCode(op)
154
155
156 DEBUG_OPT = make_option("-d", "--debug", default=False,
157                         action="store_true",
158                         help="Turn debugging on")
159
160 NOHDR_OPT = make_option("--no-headers", default=False,
161                         action="store_true", dest="no_headers",
162                         help="Don't display column headers")
163
164 SEP_OPT = make_option("--separator", default=None,
165                       action="store", dest="separator",
166                       help="Separator between output fields"
167                       " (defaults to one space)")
168
169 USEUNITS_OPT = make_option("--human-readable", default=False,
170                            action="store_true", dest="human_readable",
171                            help="Print sizes in human readable format")
172
173 FIELDS_OPT = make_option("-o", "--output", dest="output", action="store",
174                          type="string", help="Comma separated list of"
175                          " output fields",
176                          metavar="FIELDS")
177
178 FORCE_OPT = make_option("-f", "--force", dest="force", action="store_true",
179                         default=False, help="Force the operation")
180
181 TAG_SRC_OPT = make_option("--from", dest="tags_source",
182                           default=None, help="File with tag names")
183
184 SUBMIT_OPT = make_option("--submit", dest="submit_only",
185                          default=False, action="store_true",
186                          help="Submit the job and return the job ID, but"
187                          " don't wait for the job to finish")
188
189
190 def ARGS_FIXED(val):
191   """Macro-like function denoting a fixed number of arguments"""
192   return -val
193
194
195 def ARGS_ATLEAST(val):
196   """Macro-like function denoting a minimum number of arguments"""
197   return val
198
199
200 ARGS_NONE = None
201 ARGS_ONE = ARGS_FIXED(1)
202 ARGS_ANY = ARGS_ATLEAST(0)
203
204
205 def check_unit(option, opt, value):
206   """OptParsers custom converter for units.
207
208   """
209   try:
210     return utils.ParseUnit(value)
211   except errors.UnitParseError, err:
212     raise OptionValueError("option %s: %s" % (opt, err))
213
214
215 class CliOption(Option):
216   """Custom option class for optparse.
217
218   """
219   TYPES = Option.TYPES + ("unit",)
220   TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
221   TYPE_CHECKER["unit"] = check_unit
222
223
224 # optparse.py sets make_option, so we do it for our own option class, too
225 cli_option = CliOption
226
227
228 def _ParseArgs(argv, commands, aliases):
229   """Parses the command line and return the function which must be
230   executed together with its arguments
231
232   Arguments:
233     argv: the command line
234
235     commands: dictionary with special contents, see the design doc for
236     cmdline handling
237     aliases: dictionary with command aliases {'alias': 'target, ...}
238
239   """
240   if len(argv) == 0:
241     binary = "<command>"
242   else:
243     binary = argv[0].split("/")[-1]
244
245   if len(argv) > 1 and argv[1] == "--version":
246     print "%s (ganeti) %s" % (binary, constants.RELEASE_VERSION)
247     # Quit right away. That way we don't have to care about this special
248     # argument. optparse.py does it the same.
249     sys.exit(0)
250
251   if len(argv) < 2 or not (argv[1] in commands or
252                            argv[1] in aliases):
253     # let's do a nice thing
254     sortedcmds = commands.keys()
255     sortedcmds.sort()
256     print ("Usage: %(bin)s {command} [options...] [argument...]"
257            "\n%(bin)s <command> --help to see details, or"
258            " man %(bin)s\n" % {"bin": binary})
259     # compute the max line length for cmd + usage
260     mlen = max([len(" %s" % cmd) for cmd in commands])
261     mlen = min(60, mlen) # should not get here...
262     # and format a nice command list
263     print "Commands:"
264     for cmd in sortedcmds:
265       cmdstr = " %s" % (cmd,)
266       help_text = commands[cmd][4]
267       help_lines = textwrap.wrap(help_text, 79-3-mlen)
268       print "%-*s - %s" % (mlen, cmdstr, help_lines.pop(0))
269       for line in help_lines:
270         print "%-*s   %s" % (mlen, "", line)
271     print
272     return None, None, None
273
274   # get command, unalias it, and look it up in commands
275   cmd = argv.pop(1)
276   if cmd in aliases:
277     if cmd in commands:
278       raise errors.ProgrammerError("Alias '%s' overrides an existing"
279                                    " command" % cmd)
280
281     if aliases[cmd] not in commands:
282       raise errors.ProgrammerError("Alias '%s' maps to non-existing"
283                                    " command '%s'" % (cmd, aliases[cmd]))
284
285     cmd = aliases[cmd]
286
287   func, nargs, parser_opts, usage, description = commands[cmd]
288   parser = OptionParser(option_list=parser_opts,
289                         description=description,
290                         formatter=TitledHelpFormatter(),
291                         usage="%%prog %s %s" % (cmd, usage))
292   parser.disable_interspersed_args()
293   options, args = parser.parse_args()
294   if nargs is None:
295     if len(args) != 0:
296       print >> sys.stderr, ("Error: Command %s expects no arguments" % cmd)
297       return None, None, None
298   elif nargs < 0 and len(args) != -nargs:
299     print >> sys.stderr, ("Error: Command %s expects %d argument(s)" %
300                          (cmd, -nargs))
301     return None, None, None
302   elif nargs >= 0 and len(args) < nargs:
303     print >> sys.stderr, ("Error: Command %s expects at least %d argument(s)" %
304                          (cmd, nargs))
305     return None, None, None
306
307   return func, options, args
308
309
310 def SplitNodeOption(value):
311   """Splits the value of a --node option.
312
313   """
314   if value and ':' in value:
315     return value.split(':', 1)
316   else:
317     return (value, None)
318
319
320 def AskUser(text, choices=None):
321   """Ask the user a question.
322
323   Args:
324     text - the question to ask.
325
326     choices - list with elements tuples (input_char, return_value,
327     description); if not given, it will default to: [('y', True,
328     'Perform the operation'), ('n', False, 'Do no do the operation')];
329     note that the '?' char is reserved for help
330
331   Returns: one of the return values from the choices list; if input is
332   not possible (i.e. not running with a tty, we return the last entry
333   from the list
334
335   """
336   if choices is None:
337     choices = [('y', True, 'Perform the operation'),
338                ('n', False, 'Do not perform the operation')]
339   if not choices or not isinstance(choices, list):
340     raise errors.ProgrammerError("Invalid choiches argument to AskUser")
341   for entry in choices:
342     if not isinstance(entry, tuple) or len(entry) < 3 or entry[0] == '?':
343       raise errors.ProgrammerError("Invalid choiches element to AskUser")
344
345   answer = choices[-1][1]
346   new_text = []
347   for line in text.splitlines():
348     new_text.append(textwrap.fill(line, 70, replace_whitespace=False))
349   text = "\n".join(new_text)
350   try:
351     f = file("/dev/tty", "a+")
352   except IOError:
353     return answer
354   try:
355     chars = [entry[0] for entry in choices]
356     chars[-1] = "[%s]" % chars[-1]
357     chars.append('?')
358     maps = dict([(entry[0], entry[1]) for entry in choices])
359     while True:
360       f.write(text)
361       f.write('\n')
362       f.write("/".join(chars))
363       f.write(": ")
364       line = f.readline(2).strip().lower()
365       if line in maps:
366         answer = maps[line]
367         break
368       elif line == '?':
369         for entry in choices:
370           f.write(" %s - %s\n" % (entry[0], entry[2]))
371         f.write("\n")
372         continue
373   finally:
374     f.close()
375   return answer
376
377
378 class JobSubmittedException(Exception):
379   """Job was submitted, client should exit.
380
381   This exception has one argument, the ID of the job that was
382   submitted. The handler should print this ID.
383
384   This is not an error, just a structured way to exit from clients.
385
386   """
387
388
389 def SendJob(ops, cl=None):
390   """Function to submit an opcode without waiting for the results.
391
392   @type ops: list
393   @param ops: list of opcodes
394   @type cl: luxi.Client
395   @param cl: the luxi client to use for communicating with the master;
396              if None, a new client will be created
397
398   """
399   if cl is None:
400     cl = GetClient()
401
402   job_id = cl.SubmitJob(ops)
403
404   return job_id
405
406
407 def PollJob(job_id, cl=None, feedback_fn=None):
408   """Function to poll for the result of a job.
409
410   @type job_id: job identified
411   @param job_id: the job to poll for results
412   @type cl: luxi.Client
413   @param cl: the luxi client to use for communicating with the master;
414              if None, a new client will be created
415
416   """
417   if cl is None:
418     cl = GetClient()
419
420   prev_job_info = None
421   prev_logmsg_serial = None
422
423   while True:
424     result = cl.WaitForJobChange(job_id, ["status"], prev_job_info,
425                                  prev_logmsg_serial)
426     if not result:
427       # job not found, go away!
428       raise errors.JobLost("Job with id %s lost" % job_id)
429
430     # Split result, a tuple of (field values, log entries)
431     (job_info, log_entries) = result
432     (status, ) = job_info
433
434     if log_entries:
435       for log_entry in log_entries:
436         (serial, timestamp, _, message) = log_entry
437         if callable(feedback_fn):
438           feedback_fn(log_entry[1:])
439         else:
440           print "%s %s" % (time.ctime(utils.MergeTime(timestamp)), message)
441         prev_logmsg_serial = max(prev_logmsg_serial, serial)
442
443     # TODO: Handle canceled and archived jobs
444     elif status in (constants.JOB_STATUS_SUCCESS, constants.JOB_STATUS_ERROR):
445       break
446
447     prev_job_info = job_info
448
449   jobs = cl.QueryJobs([job_id], ["status", "opresult"])
450   if not jobs:
451     raise errors.JobLost("Job with id %s lost" % job_id)
452
453   status, result = jobs[0]
454   if status == constants.JOB_STATUS_SUCCESS:
455     return result
456   else:
457     raise errors.OpExecError(result)
458
459
460 def SubmitOpCode(op, cl=None, feedback_fn=None):
461   """Legacy function to submit an opcode.
462
463   This is just a simple wrapper over the construction of the processor
464   instance. It should be extended to better handle feedback and
465   interaction functions.
466
467   """
468   if cl is None:
469     cl = GetClient()
470
471   job_id = SendJob([op], cl)
472
473   op_results = PollJob(job_id, cl=cl, feedback_fn=feedback_fn)
474
475   return op_results[0]
476
477
478 def SubmitOrSend(op, opts, cl=None, feedback_fn=None):
479   """Wrapper around SubmitOpCode or SendJob.
480
481   This function will decide, based on the 'opts' parameter, whether to
482   submit and wait for the result of the opcode (and return it), or
483   whether to just send the job and print its identifier. It is used in
484   order to simplify the implementation of the '--submit' option.
485
486   """
487   if opts and opts.submit_only:
488     job_id = SendJob([op], cl=cl)
489     raise JobSubmittedException(job_id)
490   else:
491     return SubmitOpCode(op, cl=cl, feedback_fn=feedback_fn)
492
493
494 def GetClient():
495   # TODO: Cache object?
496   try:
497     client = luxi.Client()
498   except luxi.NoMasterError:
499     master, myself = ssconf.GetMasterAndMyself()
500     if master != myself:
501       raise errors.OpPrereqError("This is not the master node, please connect"
502                                  " to node '%s' and rerun the command" %
503                                  master)
504     else:
505       raise
506   return client
507
508
509 def FormatError(err):
510   """Return a formatted error message for a given error.
511
512   This function takes an exception instance and returns a tuple
513   consisting of two values: first, the recommended exit code, and
514   second, a string describing the error message (not
515   newline-terminated).
516
517   """
518   retcode = 1
519   obuf = StringIO()
520   msg = str(err)
521   if isinstance(err, errors.ConfigurationError):
522     txt = "Corrupt configuration file: %s" % msg
523     logger.Error(txt)
524     obuf.write(txt + "\n")
525     obuf.write("Aborting.")
526     retcode = 2
527   elif isinstance(err, errors.HooksAbort):
528     obuf.write("Failure: hooks execution failed:\n")
529     for node, script, out in err.args[0]:
530       if out:
531         obuf.write("  node: %s, script: %s, output: %s\n" %
532                    (node, script, out))
533       else:
534         obuf.write("  node: %s, script: %s (no output)\n" %
535                    (node, script))
536   elif isinstance(err, errors.HooksFailure):
537     obuf.write("Failure: hooks general failure: %s" % msg)
538   elif isinstance(err, errors.ResolverError):
539     this_host = utils.HostInfo.SysName()
540     if err.args[0] == this_host:
541       msg = "Failure: can't resolve my own hostname ('%s')"
542     else:
543       msg = "Failure: can't resolve hostname '%s'"
544     obuf.write(msg % err.args[0])
545   elif isinstance(err, errors.OpPrereqError):
546     obuf.write("Failure: prerequisites not met for this"
547                " operation:\n%s" % msg)
548   elif isinstance(err, errors.OpExecError):
549     obuf.write("Failure: command execution error:\n%s" % msg)
550   elif isinstance(err, errors.TagError):
551     obuf.write("Failure: invalid tag(s) given:\n%s" % msg)
552   elif isinstance(err, errors.GenericError):
553     obuf.write("Unhandled Ganeti error: %s" % msg)
554   elif isinstance(err, luxi.NoMasterError):
555     obuf.write("Cannot communicate with the master daemon.\nIs it running"
556                " and listening for connections?")
557   elif isinstance(err, luxi.TimeoutError):
558     obuf.write("Timeout while talking to the master daemon. Error:\n"
559                "%s" % msg)
560   elif isinstance(err, luxi.ProtocolError):
561     obuf.write("Unhandled protocol error while talking to the master daemon:\n"
562                "%s" % msg)
563   elif isinstance(err, JobSubmittedException):
564     obuf.write("JobID: %s\n" % err.args[0])
565     retcode = 0
566   else:
567     obuf.write("Unhandled exception: %s" % msg)
568   return retcode, obuf.getvalue().rstrip('\n')
569
570
571 def GenericMain(commands, override=None, aliases=None):
572   """Generic main function for all the gnt-* commands.
573
574   Arguments:
575     - commands: a dictionary with a special structure, see the design doc
576                 for command line handling.
577     - override: if not None, we expect a dictionary with keys that will
578                 override command line options; this can be used to pass
579                 options from the scripts to generic functions
580     - aliases: dictionary with command aliases {'alias': 'target, ...}
581
582   """
583   # save the program name and the entire command line for later logging
584   if sys.argv:
585     binary = os.path.basename(sys.argv[0]) or sys.argv[0]
586     if len(sys.argv) >= 2:
587       binary += " " + sys.argv[1]
588       old_cmdline = " ".join(sys.argv[2:])
589     else:
590       old_cmdline = ""
591   else:
592     binary = "<unknown program>"
593     old_cmdline = ""
594
595   if aliases is None:
596     aliases = {}
597
598   func, options, args = _ParseArgs(sys.argv, commands, aliases)
599   if func is None: # parse error
600     return 1
601
602   if override is not None:
603     for key, val in override.iteritems():
604       setattr(options, key, val)
605
606   logger.SetupLogging(constants.LOG_COMMANDS, debug=options.debug,
607                       stderr_logging=True, program=binary)
608
609   utils.debug = options.debug
610
611   if old_cmdline:
612     logger.Info("run with arguments '%s'" % old_cmdline)
613   else:
614     logger.Info("run with no arguments")
615
616   try:
617     result = func(options, args)
618   except (errors.GenericError, luxi.ProtocolError), err:
619     result, err_msg = FormatError(err)
620     logger.ToStderr(err_msg)
621
622   return result
623
624
625 def GenerateTable(headers, fields, separator, data,
626                   numfields=None, unitfields=None):
627   """Prints a table with headers and different fields.
628
629   Args:
630     headers: Dict of header titles or None if no headers should be shown
631     fields: List of fields to show
632     separator: String used to separate fields or None for spaces
633     data: Data to be printed
634     numfields: List of fields to be aligned to right
635     unitfields: List of fields to be formatted as units
636
637   """
638   if numfields is None:
639     numfields = []
640   if unitfields is None:
641     unitfields = []
642
643   format_fields = []
644   for field in fields:
645     if headers and field not in headers:
646       raise errors.ProgrammerError("Missing header description for field '%s'"
647                                    % field)
648     if separator is not None:
649       format_fields.append("%s")
650     elif field in numfields:
651       format_fields.append("%*s")
652     else:
653       format_fields.append("%-*s")
654
655   if separator is None:
656     mlens = [0 for name in fields]
657     format = ' '.join(format_fields)
658   else:
659     format = separator.replace("%", "%%").join(format_fields)
660
661   for row in data:
662     for idx, val in enumerate(row):
663       if fields[idx] in unitfields:
664         try:
665           val = int(val)
666         except ValueError:
667           pass
668         else:
669           val = row[idx] = utils.FormatUnit(val)
670       val = row[idx] = str(val)
671       if separator is None:
672         mlens[idx] = max(mlens[idx], len(val))
673
674   result = []
675   if headers:
676     args = []
677     for idx, name in enumerate(fields):
678       hdr = headers[name]
679       if separator is None:
680         mlens[idx] = max(mlens[idx], len(hdr))
681         args.append(mlens[idx])
682       args.append(hdr)
683     result.append(format % tuple(args))
684
685   for line in data:
686     args = []
687     for idx in xrange(len(fields)):
688       if separator is None:
689         args.append(mlens[idx])
690       args.append(line[idx])
691     result.append(format % tuple(args))
692
693   return result
694
695
696 def FormatTimestamp(ts):
697   """Formats a given timestamp.
698
699   @type ts: timestamp
700   @param ts: a timeval-type timestamp, a tuple of seconds and microseconds
701
702   @rtype: string
703   @returns: a string with the formatted timestamp
704
705   """
706   if not isinstance (ts, (tuple, list)) or len(ts) != 2:
707     return '?'
708   sec, usec = ts
709   return time.strftime("%F %T", time.localtime(sec)) + ".%06d" % usec
710
711
712 def ParseTimespec(value):
713   """Parse a time specification.
714
715   The following suffixed will be recognized:
716
717     - s: seconds
718     - m: minutes
719     - h: hours
720     - d: day
721     - w: weeks
722
723   Without any suffix, the value will be taken to be in seconds.
724
725   """
726   value = str(value)
727   if not value:
728     raise errors.OpPrereqError("Empty time specification passed")
729   suffix_map = {
730     's': 1,
731     'm': 60,
732     'h': 3600,
733     'd': 86400,
734     'w': 604800,
735     }
736   if value[-1] not in suffix_map:
737     try:
738       value = int(value)
739     except ValueError:
740       raise errors.OpPrereqError("Invalid time specification '%s'" % value)
741   else:
742     multiplier = suffix_map[value[-1]]
743     value = value[:-1]
744     if not value: # no data left after stripping the suffix
745       raise errors.OpPrereqError("Invalid time specification (only"
746                                  " suffix passed)")
747     try:
748       value = int(value) * multiplier
749     except ValueError:
750       raise errors.OpPrereqError("Invalid time specification '%s'" % value)
751   return value