Statistics
| Branch: | Tag: | Revision:

root / lib / cli.py @ 082c5adb

History | View | Annotate | Download (20.3 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
           ]
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
SUBMIT_OPT = make_option("--submit", dest="submit_only",
184
                         default=False, action="store_true",
185
                         help="Submit the job and return the job ID, but"
186
                         " don't wait for the job to finish")
187

    
188

    
189
def ARGS_FIXED(val):
190
  """Macro-like function denoting a fixed number of arguments"""
191
  return -val
192

    
193

    
194
def ARGS_ATLEAST(val):
195
  """Macro-like function denoting a minimum number of arguments"""
196
  return val
197

    
198

    
199
ARGS_NONE = None
200
ARGS_ONE = ARGS_FIXED(1)
201
ARGS_ANY = ARGS_ATLEAST(0)
202

    
203

    
204
def check_unit(option, opt, value):
205
  """OptParsers custom converter for units.
206

207
  """
208
  try:
209
    return utils.ParseUnit(value)
210
  except errors.UnitParseError, err:
211
    raise OptionValueError("option %s: %s" % (opt, err))
212

    
213

    
214
class CliOption(Option):
215
  """Custom option class for optparse.
216

217
  """
218
  TYPES = Option.TYPES + ("unit",)
219
  TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
220
  TYPE_CHECKER["unit"] = check_unit
221

    
222

    
223
# optparse.py sets make_option, so we do it for our own option class, too
224
cli_option = CliOption
225

    
226

    
227
def _ParseArgs(argv, commands, aliases):
228
  """Parses the command line and return the function which must be
229
  executed together with its arguments
230

231
  Arguments:
232
    argv: the command line
233

234
    commands: dictionary with special contents, see the design doc for
235
    cmdline handling
236
    aliases: dictionary with command aliases {'alias': 'target, ...}
237

238
  """
239
  if len(argv) == 0:
240
    binary = "<command>"
241
  else:
242
    binary = argv[0].split("/")[-1]
243

    
244
  if len(argv) > 1 and argv[1] == "--version":
245
    print "%s (ganeti) %s" % (binary, constants.RELEASE_VERSION)
246
    # Quit right away. That way we don't have to care about this special
247
    # argument. optparse.py does it the same.
248
    sys.exit(0)
249

    
250
  if len(argv) < 2 or not (argv[1] in commands or
251
                           argv[1] in aliases):
252
    # let's do a nice thing
253
    sortedcmds = commands.keys()
254
    sortedcmds.sort()
255
    print ("Usage: %(bin)s {command} [options...] [argument...]"
256
           "\n%(bin)s <command> --help to see details, or"
257
           " man %(bin)s\n" % {"bin": binary})
258
    # compute the max line length for cmd + usage
259
    mlen = max([len(" %s" % cmd) for cmd in commands])
260
    mlen = min(60, mlen) # should not get here...
261
    # and format a nice command list
262
    print "Commands:"
263
    for cmd in sortedcmds:
264
      cmdstr = " %s" % (cmd,)
265
      help_text = commands[cmd][4]
266
      help_lines = textwrap.wrap(help_text, 79-3-mlen)
267
      print "%-*s - %s" % (mlen, cmdstr, help_lines.pop(0))
268
      for line in help_lines:
269
        print "%-*s   %s" % (mlen, "", line)
270
    print
271
    return None, None, None
272

    
273
  # get command, unalias it, and look it up in commands
274
  cmd = argv.pop(1)
275
  if cmd in aliases:
276
    if cmd in commands:
277
      raise errors.ProgrammerError("Alias '%s' overrides an existing"
278
                                   " command" % cmd)
279

    
280
    if aliases[cmd] not in commands:
281
      raise errors.ProgrammerError("Alias '%s' maps to non-existing"
282
                                   " command '%s'" % (cmd, aliases[cmd]))
283

    
284
    cmd = aliases[cmd]
285

    
286
  func, nargs, parser_opts, usage, description = commands[cmd]
287
  parser = OptionParser(option_list=parser_opts,
288
                        description=description,
289
                        formatter=TitledHelpFormatter(),
290
                        usage="%%prog %s %s" % (cmd, usage))
291
  parser.disable_interspersed_args()
292
  options, args = parser.parse_args()
293
  if nargs is None:
294
    if len(args) != 0:
295
      print >> sys.stderr, ("Error: Command %s expects no arguments" % cmd)
296
      return None, None, None
297
  elif nargs < 0 and len(args) != -nargs:
298
    print >> sys.stderr, ("Error: Command %s expects %d argument(s)" %
299
                         (cmd, -nargs))
300
    return None, None, None
301
  elif nargs >= 0 and len(args) < nargs:
302
    print >> sys.stderr, ("Error: Command %s expects at least %d argument(s)" %
303
                         (cmd, nargs))
304
    return None, None, None
305

    
306
  return func, options, args
307

    
308

    
309
def SplitNodeOption(value):
310
  """Splits the value of a --node option.
311

312
  """
313
  if value and ':' in value:
314
    return value.split(':', 1)
315
  else:
316
    return (value, None)
317

    
318

    
319
def AskUser(text, choices=None):
320
  """Ask the user a question.
321

322
  Args:
323
    text - the question to ask.
324

325
    choices - list with elements tuples (input_char, return_value,
326
    description); if not given, it will default to: [('y', True,
327
    'Perform the operation'), ('n', False, 'Do no do the operation')];
328
    note that the '?' char is reserved for help
329

330
  Returns: one of the return values from the choices list; if input is
331
  not possible (i.e. not running with a tty, we return the last entry
332
  from the list
333

334
  """
335
  if choices is None:
336
    choices = [('y', True, 'Perform the operation'),
337
               ('n', False, 'Do not perform the operation')]
338
  if not choices or not isinstance(choices, list):
339
    raise errors.ProgrammerError("Invalid choiches argument to AskUser")
340
  for entry in choices:
341
    if not isinstance(entry, tuple) or len(entry) < 3 or entry[0] == '?':
342
      raise errors.ProgrammerError("Invalid choiches element to AskUser")
343

    
344
  answer = choices[-1][1]
345
  new_text = []
346
  for line in text.splitlines():
347
    new_text.append(textwrap.fill(line, 70, replace_whitespace=False))
348
  text = "\n".join(new_text)
349
  try:
350
    f = file("/dev/tty", "a+")
351
  except IOError:
352
    return answer
353
  try:
354
    chars = [entry[0] for entry in choices]
355
    chars[-1] = "[%s]" % chars[-1]
356
    chars.append('?')
357
    maps = dict([(entry[0], entry[1]) for entry in choices])
358
    while True:
359
      f.write(text)
360
      f.write('\n')
361
      f.write("/".join(chars))
362
      f.write(": ")
363
      line = f.readline(2).strip().lower()
364
      if line in maps:
365
        answer = maps[line]
366
        break
367
      elif line == '?':
368
        for entry in choices:
369
          f.write(" %s - %s\n" % (entry[0], entry[2]))
370
        f.write("\n")
371
        continue
372
  finally:
373
    f.close()
374
  return answer
375

    
376

    
377
def SendJob(ops, cl=None):
378
  """Function to submit an opcode without waiting for the results.
379

380
  @type ops: list
381
  @param ops: list of opcodes
382
  @type cl: luxi.Client
383
  @param cl: the luxi client to use for communicating with the master;
384
             if None, a new client will be created
385

386
  """
387
  if cl is None:
388
    cl = GetClient()
389

    
390
  job_id = cl.SubmitJob(ops)
391

    
392
  return job_id
393

    
394

    
395
def PollJob(job_id, cl=None, feedback_fn=None):
396
  """Function to poll for the result of a job.
397

398
  @type job_id: job identified
399
  @param job_id: the job to poll for results
400
  @type cl: luxi.Client
401
  @param cl: the luxi client to use for communicating with the master;
402
             if None, a new client will be created
403

404
  """
405
  if cl is None:
406
    cl = GetClient()
407

    
408
  prev_job_info = None
409
  prev_logmsg_serial = None
410

    
411
  while True:
412
    result = cl.WaitForJobChange(job_id, ["status"], prev_job_info,
413
                                 prev_logmsg_serial)
414
    if not result:
415
      # job not found, go away!
416
      raise errors.JobLost("Job with id %s lost" % job_id)
417

    
418
    # Split result, a tuple of (field values, log entries)
419
    (job_info, log_entries) = result
420
    (status, ) = job_info
421

    
422
    if log_entries:
423
      for log_entry in log_entries:
424
        (serial, timestamp, _, message) = log_entry
425
        if callable(feedback_fn):
426
          feedback_fn(log_entry[1:])
427
        else:
428
          print "%s %s" % (time.ctime(utils.MergeTime(timestamp)), message)
429
        prev_logmsg_serial = max(prev_logmsg_serial, serial)
430

    
431
    # TODO: Handle canceled and archived jobs
432
    elif status in (constants.JOB_STATUS_SUCCESS, constants.JOB_STATUS_ERROR):
433
      break
434

    
435
    prev_job_info = job_info
436

    
437
  jobs = cl.QueryJobs([job_id], ["status", "opresult"])
438
  if not jobs:
439
    raise errors.JobLost("Job with id %s lost" % job_id)
440

    
441
  status, result = jobs[0]
442
  if status == constants.JOB_STATUS_SUCCESS:
443
    return result[0]
444
  else:
445
    raise errors.OpExecError(result)
446

    
447

    
448
def SubmitOpCode(op, cl=None, feedback_fn=None):
449
  """Legacy function to submit an opcode.
450

451
  This is just a simple wrapper over the construction of the processor
452
  instance. It should be extended to better handle feedback and
453
  interaction functions.
454

455
  """
456
  if cl is None:
457
    cl = GetClient()
458

    
459
  job_id = SendJob([op], cl)
460

    
461
  return PollJob(job_id, cl=cl, feedback_fn=feedback_fn)
462

    
463

    
464
def SubmitOrSend(op, opts, cl=None, feedback_fn=None):
465
  """Wrapper around SubmitOpCode or SendJob.
466

467
  This function will decide, based on the 'opts' parameter, whether to
468
  submit and wait for the result of the opcode (and return it), or
469
  whether to just send the job and print its identifier. It is used in
470
  order to simplify the implementation of the '--submit' option.
471

472
  """
473
  if opts and opts.submit_only:
474
    print SendJob([op], cl=cl)
475
    sys.exit(0)
476
  else:
477
    return SubmitOpCode(op, cl=cl, feedback_fn=feedback_fn)
478

    
479

    
480
def GetClient():
481
  # TODO: Cache object?
482
  try:
483
    client = luxi.Client()
484
  except luxi.NoMasterError:
485
    master, myself = ssconf.GetMasterAndMyself()
486
    if master != myself:
487
      raise errors.OpPrereqError("This is not the master node, please connect"
488
                                 " to node '%s' and rerun the command" %
489
                                 master)
490
    else:
491
      raise
492
  return client
493

    
494

    
495
def FormatError(err):
496
  """Return a formatted error message for a given error.
497

498
  This function takes an exception instance and returns a tuple
499
  consisting of two values: first, the recommended exit code, and
500
  second, a string describing the error message (not
501
  newline-terminated).
502

503
  """
504
  retcode = 1
505
  obuf = StringIO()
506
  msg = str(err)
507
  if isinstance(err, errors.ConfigurationError):
508
    txt = "Corrupt configuration file: %s" % msg
509
    logger.Error(txt)
510
    obuf.write(txt + "\n")
511
    obuf.write("Aborting.")
512
    retcode = 2
513
  elif isinstance(err, errors.HooksAbort):
514
    obuf.write("Failure: hooks execution failed:\n")
515
    for node, script, out in err.args[0]:
516
      if out:
517
        obuf.write("  node: %s, script: %s, output: %s\n" %
518
                   (node, script, out))
519
      else:
520
        obuf.write("  node: %s, script: %s (no output)\n" %
521
                   (node, script))
522
  elif isinstance(err, errors.HooksFailure):
523
    obuf.write("Failure: hooks general failure: %s" % msg)
524
  elif isinstance(err, errors.ResolverError):
525
    this_host = utils.HostInfo.SysName()
526
    if err.args[0] == this_host:
527
      msg = "Failure: can't resolve my own hostname ('%s')"
528
    else:
529
      msg = "Failure: can't resolve hostname '%s'"
530
    obuf.write(msg % err.args[0])
531
  elif isinstance(err, errors.OpPrereqError):
532
    obuf.write("Failure: prerequisites not met for this"
533
               " operation:\n%s" % msg)
534
  elif isinstance(err, errors.OpExecError):
535
    obuf.write("Failure: command execution error:\n%s" % msg)
536
  elif isinstance(err, errors.TagError):
537
    obuf.write("Failure: invalid tag(s) given:\n%s" % msg)
538
  elif isinstance(err, errors.GenericError):
539
    obuf.write("Unhandled Ganeti error: %s" % msg)
540
  elif isinstance(err, luxi.NoMasterError):
541
    obuf.write("Cannot communicate with the master daemon.\nIs it running"
542
               " and listening for connections?")
543
  elif isinstance(err, luxi.TimeoutError):
544
    obuf.write("Timeout while talking to the master daemon. Error:\n"
545
               "%s" % msg)
546
  elif isinstance(err, luxi.ProtocolError):
547
    obuf.write("Unhandled protocol error while talking to the master daemon:\n"
548
               "%s" % msg)
549
  else:
550
    obuf.write("Unhandled exception: %s" % msg)
551
  return retcode, obuf.getvalue().rstrip('\n')
552

    
553

    
554
def GenericMain(commands, override=None, aliases=None):
555
  """Generic main function for all the gnt-* commands.
556

557
  Arguments:
558
    - commands: a dictionary with a special structure, see the design doc
559
                for command line handling.
560
    - override: if not None, we expect a dictionary with keys that will
561
                override command line options; this can be used to pass
562
                options from the scripts to generic functions
563
    - aliases: dictionary with command aliases {'alias': 'target, ...}
564

565
  """
566
  # save the program name and the entire command line for later logging
567
  if sys.argv:
568
    binary = os.path.basename(sys.argv[0]) or sys.argv[0]
569
    if len(sys.argv) >= 2:
570
      binary += " " + sys.argv[1]
571
      old_cmdline = " ".join(sys.argv[2:])
572
    else:
573
      old_cmdline = ""
574
  else:
575
    binary = "<unknown program>"
576
    old_cmdline = ""
577

    
578
  if aliases is None:
579
    aliases = {}
580

    
581
  func, options, args = _ParseArgs(sys.argv, commands, aliases)
582
  if func is None: # parse error
583
    return 1
584

    
585
  if override is not None:
586
    for key, val in override.iteritems():
587
      setattr(options, key, val)
588

    
589
  logger.SetupLogging(constants.LOG_COMMANDS, debug=options.debug,
590
                      stderr_logging=True, program=binary)
591

    
592
  utils.debug = options.debug
593

    
594
  if old_cmdline:
595
    logger.Info("run with arguments '%s'" % old_cmdline)
596
  else:
597
    logger.Info("run with no arguments")
598

    
599
  try:
600
    result = func(options, args)
601
  except (errors.GenericError, luxi.ProtocolError), err:
602
    result, err_msg = FormatError(err)
603
    logger.ToStderr(err_msg)
604

    
605
  return result
606

    
607

    
608
def GenerateTable(headers, fields, separator, data,
609
                  numfields=None, unitfields=None):
610
  """Prints a table with headers and different fields.
611

612
  Args:
613
    headers: Dict of header titles or None if no headers should be shown
614
    fields: List of fields to show
615
    separator: String used to separate fields or None for spaces
616
    data: Data to be printed
617
    numfields: List of fields to be aligned to right
618
    unitfields: List of fields to be formatted as units
619

620
  """
621
  if numfields is None:
622
    numfields = []
623
  if unitfields is None:
624
    unitfields = []
625

    
626
  format_fields = []
627
  for field in fields:
628
    if headers and field not in headers:
629
      raise errors.ProgrammerError("Missing header description for field '%s'"
630
                                   % field)
631
    if separator is not None:
632
      format_fields.append("%s")
633
    elif field in numfields:
634
      format_fields.append("%*s")
635
    else:
636
      format_fields.append("%-*s")
637

    
638
  if separator is None:
639
    mlens = [0 for name in fields]
640
    format = ' '.join(format_fields)
641
  else:
642
    format = separator.replace("%", "%%").join(format_fields)
643

    
644
  for row in data:
645
    for idx, val in enumerate(row):
646
      if fields[idx] in unitfields:
647
        try:
648
          val = int(val)
649
        except ValueError:
650
          pass
651
        else:
652
          val = row[idx] = utils.FormatUnit(val)
653
      val = row[idx] = str(val)
654
      if separator is None:
655
        mlens[idx] = max(mlens[idx], len(val))
656

    
657
  result = []
658
  if headers:
659
    args = []
660
    for idx, name in enumerate(fields):
661
      hdr = headers[name]
662
      if separator is None:
663
        mlens[idx] = max(mlens[idx], len(hdr))
664
        args.append(mlens[idx])
665
      args.append(hdr)
666
    result.append(format % tuple(args))
667

    
668
  for line in data:
669
    args = []
670
    for idx in xrange(len(fields)):
671
      if separator is None:
672
        args.append(mlens[idx])
673
      args.append(line[idx])
674
    result.append(format % tuple(args))
675

    
676
  return result