Abstract the timestamp formatting into cli.py
[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[0]
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   return PollJob(job_id, cl=cl, feedback_fn=feedback_fn)
474
475
476 def SubmitOrSend(op, opts, cl=None, feedback_fn=None):
477   """Wrapper around SubmitOpCode or SendJob.
478
479   This function will decide, based on the 'opts' parameter, whether to
480   submit and wait for the result of the opcode (and return it), or
481   whether to just send the job and print its identifier. It is used in
482   order to simplify the implementation of the '--submit' option.
483
484   """
485   if opts and opts.submit_only:
486     job_id = SendJob([op], cl=cl)
487     raise JobSubmittedException(job_id)
488   else:
489     return SubmitOpCode(op, cl=cl, feedback_fn=feedback_fn)
490
491
492 def GetClient():
493   # TODO: Cache object?
494   try:
495     client = luxi.Client()
496   except luxi.NoMasterError:
497     master, myself = ssconf.GetMasterAndMyself()
498     if master != myself:
499       raise errors.OpPrereqError("This is not the master node, please connect"
500                                  " to node '%s' and rerun the command" %
501                                  master)
502     else:
503       raise
504   return client
505
506
507 def FormatError(err):
508   """Return a formatted error message for a given error.
509
510   This function takes an exception instance and returns a tuple
511   consisting of two values: first, the recommended exit code, and
512   second, a string describing the error message (not
513   newline-terminated).
514
515   """
516   retcode = 1
517   obuf = StringIO()
518   msg = str(err)
519   if isinstance(err, errors.ConfigurationError):
520     txt = "Corrupt configuration file: %s" % msg
521     logger.Error(txt)
522     obuf.write(txt + "\n")
523     obuf.write("Aborting.")
524     retcode = 2
525   elif isinstance(err, errors.HooksAbort):
526     obuf.write("Failure: hooks execution failed:\n")
527     for node, script, out in err.args[0]:
528       if out:
529         obuf.write("  node: %s, script: %s, output: %s\n" %
530                    (node, script, out))
531       else:
532         obuf.write("  node: %s, script: %s (no output)\n" %
533                    (node, script))
534   elif isinstance(err, errors.HooksFailure):
535     obuf.write("Failure: hooks general failure: %s" % msg)
536   elif isinstance(err, errors.ResolverError):
537     this_host = utils.HostInfo.SysName()
538     if err.args[0] == this_host:
539       msg = "Failure: can't resolve my own hostname ('%s')"
540     else:
541       msg = "Failure: can't resolve hostname '%s'"
542     obuf.write(msg % err.args[0])
543   elif isinstance(err, errors.OpPrereqError):
544     obuf.write("Failure: prerequisites not met for this"
545                " operation:\n%s" % msg)
546   elif isinstance(err, errors.OpExecError):
547     obuf.write("Failure: command execution error:\n%s" % msg)
548   elif isinstance(err, errors.TagError):
549     obuf.write("Failure: invalid tag(s) given:\n%s" % msg)
550   elif isinstance(err, errors.GenericError):
551     obuf.write("Unhandled Ganeti error: %s" % msg)
552   elif isinstance(err, luxi.NoMasterError):
553     obuf.write("Cannot communicate with the master daemon.\nIs it running"
554                " and listening for connections?")
555   elif isinstance(err, luxi.TimeoutError):
556     obuf.write("Timeout while talking to the master daemon. Error:\n"
557                "%s" % msg)
558   elif isinstance(err, luxi.ProtocolError):
559     obuf.write("Unhandled protocol error while talking to the master daemon:\n"
560                "%s" % msg)
561   elif isinstance(err, JobSubmittedException):
562     obuf.write("JobID: %s\n" % err.args[0])
563     retcode = 0
564   else:
565     obuf.write("Unhandled exception: %s" % msg)
566   return retcode, obuf.getvalue().rstrip('\n')
567
568
569 def GenericMain(commands, override=None, aliases=None):
570   """Generic main function for all the gnt-* commands.
571
572   Arguments:
573     - commands: a dictionary with a special structure, see the design doc
574                 for command line handling.
575     - override: if not None, we expect a dictionary with keys that will
576                 override command line options; this can be used to pass
577                 options from the scripts to generic functions
578     - aliases: dictionary with command aliases {'alias': 'target, ...}
579
580   """
581   # save the program name and the entire command line for later logging
582   if sys.argv:
583     binary = os.path.basename(sys.argv[0]) or sys.argv[0]
584     if len(sys.argv) >= 2:
585       binary += " " + sys.argv[1]
586       old_cmdline = " ".join(sys.argv[2:])
587     else:
588       old_cmdline = ""
589   else:
590     binary = "<unknown program>"
591     old_cmdline = ""
592
593   if aliases is None:
594     aliases = {}
595
596   func, options, args = _ParseArgs(sys.argv, commands, aliases)
597   if func is None: # parse error
598     return 1
599
600   if override is not None:
601     for key, val in override.iteritems():
602       setattr(options, key, val)
603
604   logger.SetupLogging(constants.LOG_COMMANDS, debug=options.debug,
605                       stderr_logging=True, program=binary)
606
607   utils.debug = options.debug
608
609   if old_cmdline:
610     logger.Info("run with arguments '%s'" % old_cmdline)
611   else:
612     logger.Info("run with no arguments")
613
614   try:
615     result = func(options, args)
616   except (errors.GenericError, luxi.ProtocolError), err:
617     result, err_msg = FormatError(err)
618     logger.ToStderr(err_msg)
619
620   return result
621
622
623 def GenerateTable(headers, fields, separator, data,
624                   numfields=None, unitfields=None):
625   """Prints a table with headers and different fields.
626
627   Args:
628     headers: Dict of header titles or None if no headers should be shown
629     fields: List of fields to show
630     separator: String used to separate fields or None for spaces
631     data: Data to be printed
632     numfields: List of fields to be aligned to right
633     unitfields: List of fields to be formatted as units
634
635   """
636   if numfields is None:
637     numfields = []
638   if unitfields is None:
639     unitfields = []
640
641   format_fields = []
642   for field in fields:
643     if headers and field not in headers:
644       raise errors.ProgrammerError("Missing header description for field '%s'"
645                                    % field)
646     if separator is not None:
647       format_fields.append("%s")
648     elif field in numfields:
649       format_fields.append("%*s")
650     else:
651       format_fields.append("%-*s")
652
653   if separator is None:
654     mlens = [0 for name in fields]
655     format = ' '.join(format_fields)
656   else:
657     format = separator.replace("%", "%%").join(format_fields)
658
659   for row in data:
660     for idx, val in enumerate(row):
661       if fields[idx] in unitfields:
662         try:
663           val = int(val)
664         except ValueError:
665           pass
666         else:
667           val = row[idx] = utils.FormatUnit(val)
668       val = row[idx] = str(val)
669       if separator is None:
670         mlens[idx] = max(mlens[idx], len(val))
671
672   result = []
673   if headers:
674     args = []
675     for idx, name in enumerate(fields):
676       hdr = headers[name]
677       if separator is None:
678         mlens[idx] = max(mlens[idx], len(hdr))
679         args.append(mlens[idx])
680       args.append(hdr)
681     result.append(format % tuple(args))
682
683   for line in data:
684     args = []
685     for idx in xrange(len(fields)):
686       if separator is None:
687         args.append(mlens[idx])
688       args.append(line[idx])
689     result.append(format % tuple(args))
690
691   return result
692
693
694 def FormatTimestamp(ts):
695   """Formats a given timestamp.
696
697   @type ts: timestamp
698   @param ts: a timeval-type timestamp, a tuple of seconds and microseconds
699
700   @rtype: string
701   @returns: a string with the formatted timestamp
702
703   """
704   sec, usec = ts
705   return time.strftime("%F %T", time.localtime(sec)) + ".%06d" % usec