Statistics
| Branch: | Tag: | Revision:

root / lib / cli.py @ a4af651e

History | View | Annotate | Download (17.8 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 mcpu
36
from ganeti import constants
37
from ganeti import opcodes
38
from ganeti import luxi
39

    
40
from optparse import (OptionParser, make_option, TitledHelpFormatter,
41
                      Option, OptionValueError, SUPPRESS_HELP)
42

    
43
__all__ = ["DEBUG_OPT", "NOHDR_OPT", "SEP_OPT", "GenericMain",
44
           "SubmitOpCode", "SubmitJob", "SubmitQuery",
45
           "cli_option", "GenerateTable", "AskUser",
46
           "ARGS_NONE", "ARGS_FIXED", "ARGS_ATLEAST", "ARGS_ANY", "ARGS_ONE",
47
           "USEUNITS_OPT", "FIELDS_OPT", "FORCE_OPT",
48
           "ListTags", "AddTags", "RemoveTags", "TAG_SRC_OPT",
49
           "FormatError", "SplitNodeOption"
50
           ]
51

    
52

    
53
def _ExtractTagsObject(opts, args):
54
  """Extract the tag type object.
55

56
  Note that this function will modify its args parameter.
57

58
  """
59
  if not hasattr(opts, "tag_type"):
60
    raise errors.ProgrammerError("tag_type not passed to _ExtractTagsObject")
61
  kind = opts.tag_type
62
  if kind == constants.TAG_CLUSTER:
63
    retval = kind, kind
64
  elif kind == constants.TAG_NODE or kind == constants.TAG_INSTANCE:
65
    if not args:
66
      raise errors.OpPrereqError("no arguments passed to the command")
67
    name = args.pop(0)
68
    retval = kind, name
69
  else:
70
    raise errors.ProgrammerError("Unhandled tag type '%s'" % kind)
71
  return retval
72

    
73

    
74
def _ExtendTags(opts, args):
75
  """Extend the args if a source file has been given.
76

77
  This function will extend the tags with the contents of the file
78
  passed in the 'tags_source' attribute of the opts parameter. A file
79
  named '-' will be replaced by stdin.
80

81
  """
82
  fname = opts.tags_source
83
  if fname is None:
84
    return
85
  if fname == "-":
86
    new_fh = sys.stdin
87
  else:
88
    new_fh = open(fname, "r")
89
  new_data = []
90
  try:
91
    # we don't use the nice 'new_data = [line.strip() for line in fh]'
92
    # because of python bug 1633941
93
    while True:
94
      line = new_fh.readline()
95
      if not line:
96
        break
97
      new_data.append(line.strip())
98
  finally:
99
    new_fh.close()
100
  args.extend(new_data)
101

    
102

    
103
def ListTags(opts, args):
104
  """List the tags on a given object.
105

106
  This is a generic implementation that knows how to deal with all
107
  three cases of tag objects (cluster, node, instance). The opts
108
  argument is expected to contain a tag_type field denoting what
109
  object type we work on.
110

111
  """
112
  kind, name = _ExtractTagsObject(opts, args)
113
  op = opcodes.OpGetTags(kind=kind, name=name)
114
  result = SubmitOpCode(op)
115
  result = list(result)
116
  result.sort()
117
  for tag in result:
118
    print tag
119

    
120

    
121
def AddTags(opts, args):
122
  """Add tags on a given object.
123

124
  This is a generic implementation that knows how to deal with all
125
  three cases of tag objects (cluster, node, instance). The opts
126
  argument is expected to contain a tag_type field denoting what
127
  object type we work on.
128

129
  """
130
  kind, name = _ExtractTagsObject(opts, args)
131
  _ExtendTags(opts, args)
132
  if not args:
133
    raise errors.OpPrereqError("No tags to be added")
134
  op = opcodes.OpAddTags(kind=kind, name=name, tags=args)
135
  SubmitOpCode(op)
136

    
137

    
138
def RemoveTags(opts, args):
139
  """Remove tags from a given object.
140

141
  This is a generic implementation that knows how to deal with all
142
  three cases of tag objects (cluster, node, instance). The opts
143
  argument is expected to contain a tag_type field denoting what
144
  object type we work on.
145

146
  """
147
  kind, name = _ExtractTagsObject(opts, args)
148
  _ExtendTags(opts, args)
149
  if not args:
150
    raise errors.OpPrereqError("No tags to be removed")
151
  op = opcodes.OpDelTags(kind=kind, name=name, tags=args)
152
  SubmitOpCode(op)
153

    
154

    
155
DEBUG_OPT = make_option("-d", "--debug", default=False,
156
                        action="store_true",
157
                        help="Turn debugging on")
158

    
159
NOHDR_OPT = make_option("--no-headers", default=False,
160
                        action="store_true", dest="no_headers",
161
                        help="Don't display column headers")
162

    
163
SEP_OPT = make_option("--separator", default=None,
164
                      action="store", dest="separator",
165
                      help="Separator between output fields"
166
                      " (defaults to one space)")
167

    
168
USEUNITS_OPT = make_option("--human-readable", default=False,
169
                           action="store_true", dest="human_readable",
170
                           help="Print sizes in human readable format")
171

    
172
FIELDS_OPT = make_option("-o", "--output", dest="output", action="store",
173
                         type="string", help="Comma separated list of"
174
                         " output fields",
175
                         metavar="FIELDS")
176

    
177
FORCE_OPT = make_option("-f", "--force", dest="force", action="store_true",
178
                        default=False, help="Force the operation")
179

    
180
TAG_SRC_OPT = make_option("--from", dest="tags_source",
181
                          default=None, help="File with tag names")
182

    
183

    
184
def ARGS_FIXED(val):
185
  """Macro-like function denoting a fixed number of arguments"""
186
  return -val
187

    
188

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

    
193

    
194
ARGS_NONE = None
195
ARGS_ONE = ARGS_FIXED(1)
196
ARGS_ANY = ARGS_ATLEAST(0)
197

    
198

    
199
def check_unit(option, opt, value):
200
  """OptParsers custom converter for units.
201

202
  """
203
  try:
204
    return utils.ParseUnit(value)
205
  except errors.UnitParseError, err:
206
    raise OptionValueError("option %s: %s" % (opt, err))
207

    
208

    
209
class CliOption(Option):
210
  """Custom option class for optparse.
211

212
  """
213
  TYPES = Option.TYPES + ("unit",)
214
  TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
215
  TYPE_CHECKER["unit"] = check_unit
216

    
217

    
218
# optparse.py sets make_option, so we do it for our own option class, too
219
cli_option = CliOption
220

    
221

    
222
def _ParseArgs(argv, commands, aliases):
223
  """Parses the command line and return the function which must be
224
  executed together with its arguments
225

226
  Arguments:
227
    argv: the command line
228

229
    commands: dictionary with special contents, see the design doc for
230
    cmdline handling
231
    aliases: dictionary with command aliases {'alias': 'target, ...}
232

233
  """
234
  if len(argv) == 0:
235
    binary = "<command>"
236
  else:
237
    binary = argv[0].split("/")[-1]
238

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

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

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

    
276
    if aliases[cmd] not in commands:
277
      raise errors.ProgrammerError("Alias '%s' maps to non-existing"
278
                                   " command '%s'" % (cmd, aliases[cmd]))
279

    
280
    cmd = aliases[cmd]
281

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

    
302
  return func, options, args
303

    
304

    
305
def SplitNodeOption(value):
306
  """Splits the value of a --node option.
307

308
  """
309
  if value and ':' in value:
310
    return value.split(':', 1)
311
  else:
312
    return (value, None)
313

    
314

    
315
def AskUser(text, choices=None):
316
  """Ask the user a question.
317

318
  Args:
319
    text - the question to ask.
320

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

326
  Returns: one of the return values from the choices list; if input is
327
  not possible (i.e. not running with a tty, we return the last entry
328
  from the list
329

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

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

    
372

    
373
def SubmitOpCode(op, proc=None, feedback_fn=None):
374
  """Function to submit an opcode.
375

376
  This is just a simple wrapper over the construction of the processor
377
  instance. It should be extended to better handle feedback and
378
  interaction functions.
379

380
  """
381
  cl = luxi.Client()
382
  job = opcodes.Job(op_list=[op])
383
  jid = SubmitJob(job)
384

    
385
  query = {
386
    "object": "jobs",
387
    "fields": ["status"],
388
    "names": [jid],
389
    }
390

    
391
  while True:
392
    jdata = SubmitQuery(query)
393
    if not jdata:
394
      # job not found, go away!
395
      raise errors.JobLost("Job with id %s lost" % jid)
396

    
397
    status = jdata[0][0]
398
    if status in (opcodes.Job.STATUS_SUCCESS, opcodes.Job.STATUS_FAIL):
399
      break
400
    time.sleep(1)
401

    
402
  query["fields"].extend(["op_list", "op_status", "op_result"])
403
  jdata = SubmitQuery(query)
404
  if not jdata:
405
    raise errors.JobLost("Job with id %s lost" % jid)
406
  status, op_list, op_status, op_result = jdata[0]
407
  if status != opcodes.Job.STATUS_SUCCESS:
408
    raise errors.OpExecError(op_result[0])
409
  return op_result[0]
410

    
411
  if feedback_fn is None:
412
    feedback_fn = logger.ToStdout
413
  if proc is None:
414
    proc = mcpu.Processor(feedback=feedback_fn)
415
  return proc.ExecOpCode(op)
416

    
417

    
418
def SubmitJob(job, cl=None):
419
  if cl is None:
420
    cl = luxi.Client()
421
  return cl.SubmitJob(job)
422

    
423

    
424
def SubmitQuery(data, cl=None):
425
  if cl is None:
426
    cl = luxi.Client()
427
  return cl.Query(data)
428

    
429

    
430
def FormatError(err):
431
  """Return a formatted error message for a given error.
432

433
  This function takes an exception instance and returns a tuple
434
  consisting of two values: first, the recommended exit code, and
435
  second, a string describing the error message (not
436
  newline-terminated).
437

438
  """
439
  retcode = 1
440
  obuf = StringIO()
441
  msg = str(err)
442
  if isinstance(err, errors.ConfigurationError):
443
    txt = "Corrupt configuration file: %s" % msg
444
    logger.Error(txt)
445
    obuf.write(txt + "\n")
446
    obuf.write("Aborting.")
447
    retcode = 2
448
  elif isinstance(err, errors.HooksAbort):
449
    obuf.write("Failure: hooks execution failed:\n")
450
    for node, script, out in err.args[0]:
451
      if out:
452
        obuf.write("  node: %s, script: %s, output: %s\n" %
453
                   (node, script, out))
454
      else:
455
        obuf.write("  node: %s, script: %s (no output)\n" %
456
                   (node, script))
457
  elif isinstance(err, errors.HooksFailure):
458
    obuf.write("Failure: hooks general failure: %s" % msg)
459
  elif isinstance(err, errors.ResolverError):
460
    this_host = utils.HostInfo.SysName()
461
    if err.args[0] == this_host:
462
      msg = "Failure: can't resolve my own hostname ('%s')"
463
    else:
464
      msg = "Failure: can't resolve hostname '%s'"
465
    obuf.write(msg % err.args[0])
466
  elif isinstance(err, errors.OpPrereqError):
467
    obuf.write("Failure: prerequisites not met for this"
468
               " operation:\n%s" % msg)
469
  elif isinstance(err, errors.OpExecError):
470
    obuf.write("Failure: command execution error:\n%s" % msg)
471
  elif isinstance(err, errors.TagError):
472
    obuf.write("Failure: invalid tag(s) given:\n%s" % msg)
473
  elif isinstance(err, errors.GenericError):
474
    obuf.write("Unhandled Ganeti error: %s" % msg)
475
  else:
476
    obuf.write("Unhandled exception: %s" % msg)
477
  return retcode, obuf.getvalue().rstrip('\n')
478

    
479

    
480
def GenericMain(commands, override=None, aliases=None):
481
  """Generic main function for all the gnt-* commands.
482

483
  Arguments:
484
    - commands: a dictionary with a special structure, see the design doc
485
                for command line handling.
486
    - override: if not None, we expect a dictionary with keys that will
487
                override command line options; this can be used to pass
488
                options from the scripts to generic functions
489
    - aliases: dictionary with command aliases {'alias': 'target, ...}
490

491
  """
492
  # save the program name and the entire command line for later logging
493
  if sys.argv:
494
    binary = os.path.basename(sys.argv[0]) or sys.argv[0]
495
    if len(sys.argv) >= 2:
496
      binary += " " + sys.argv[1]
497
      old_cmdline = " ".join(sys.argv[2:])
498
    else:
499
      old_cmdline = ""
500
  else:
501
    binary = "<unknown program>"
502
    old_cmdline = ""
503

    
504
  if aliases is None:
505
    aliases = {}
506

    
507
  func, options, args = _ParseArgs(sys.argv, commands, aliases)
508
  if func is None: # parse error
509
    return 1
510

    
511
  if override is not None:
512
    for key, val in override.iteritems():
513
      setattr(options, key, val)
514

    
515
  logger.SetupLogging(debug=options.debug, program=binary)
516

    
517
  utils.debug = options.debug
518

    
519
  if old_cmdline:
520
    logger.Info("run with arguments '%s'" % old_cmdline)
521
  else:
522
    logger.Info("run with no arguments")
523

    
524
  try:
525
    result = func(options, args)
526
  except errors.GenericError, err:
527
    result, err_msg = FormatError(err)
528
    logger.ToStderr(err_msg)
529

    
530
  return result
531

    
532

    
533
def GenerateTable(headers, fields, separator, data,
534
                  numfields=None, unitfields=None):
535
  """Prints a table with headers and different fields.
536

537
  Args:
538
    headers: Dict of header titles or None if no headers should be shown
539
    fields: List of fields to show
540
    separator: String used to separate fields or None for spaces
541
    data: Data to be printed
542
    numfields: List of fields to be aligned to right
543
    unitfields: List of fields to be formatted as units
544

545
  """
546
  if numfields is None:
547
    numfields = []
548
  if unitfields is None:
549
    unitfields = []
550

    
551
  format_fields = []
552
  for field in fields:
553
    if headers and field not in headers:
554
      raise errors.ProgrammerError("Missing header description for field '%s'"
555
                                   % field)
556
    if separator is not None:
557
      format_fields.append("%s")
558
    elif field in numfields:
559
      format_fields.append("%*s")
560
    else:
561
      format_fields.append("%-*s")
562

    
563
  if separator is None:
564
    mlens = [0 for name in fields]
565
    format = ' '.join(format_fields)
566
  else:
567
    format = separator.replace("%", "%%").join(format_fields)
568

    
569
  for row in data:
570
    for idx, val in enumerate(row):
571
      if fields[idx] in unitfields:
572
        try:
573
          val = int(val)
574
        except ValueError:
575
          pass
576
        else:
577
          val = row[idx] = utils.FormatUnit(val)
578
      val = row[idx] = str(val)
579
      if separator is None:
580
        mlens[idx] = max(mlens[idx], len(val))
581

    
582
  result = []
583
  if headers:
584
    args = []
585
    for idx, name in enumerate(fields):
586
      hdr = headers[name]
587
      if separator is None:
588
        mlens[idx] = max(mlens[idx], len(hdr))
589
        args.append(mlens[idx])
590
      args.append(hdr)
591
    result.append(format % tuple(args))
592

    
593
  for line in data:
594
    args = []
595
    for idx in xrange(len(fields)):
596
      if separator is None:
597
        args.append(mlens[idx])
598
      args.append(line[idx])
599
    result.append(format % tuple(args))
600

    
601
  return result