Statistics
| Branch: | Tag: | Revision:

root / lib / cli.py @ 53c04d04

History | View | Annotate | Download (21.1 kB)

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
  sec, usec = ts
707
  return time.strftime("%F %T", time.localtime(sec)) + ".%06d" % usec