qa_utils.py 26.1 KB
Newer Older
1
2
3
#
#

4
# Copyright (C) 2007, 2011, 2012, 2013 Google Inc.
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.


"""Utilities for QA tests.

"""

26
import copy
27
import datetime
28
import operator
29
import os
30
import random
31
import re
32
import socket
33
import subprocess
34
import sys
35
import tempfile
36
import yaml
37

38
39
40
41
42
try:
  import functools
except ImportError, err:
  raise ImportError("Python 2.5 or higher is required: %s" % err)

43
from ganeti import utils
44
from ganeti import compat
45
from ganeti import constants
46
from ganeti import ht
47
from ganeti import pathutils
48
from ganeti import vcluster
49

50
import colors
51
52
53
import qa_config
import qa_error

54
from qa_logging import FormatInfo
55

Michael Hanselmann's avatar
Michael Hanselmann committed
56

57
58
_MULTIPLEXERS = {}

59
60
61
#: Unique ID per QA run
_RUN_UUID = utils.NewUUID()

62
63
64
#: Path to the QA query output log file
_QA_OUTPUT = pathutils.GetLogFilename("qa-output")

65
66
67
68
69
70
71

(INST_DOWN,
 INST_UP) = range(500, 502)

(FIRST_ARG,
 RETURN_VALUE) = range(1000, 1002)

Michael Hanselmann's avatar
Michael Hanselmann committed
72

73
74
75
76
77
78
79
80
81
82
83
84
def _RaiseWithInfo(msg, error_desc):
  """Raises a QA error with the given content, and adds a message if present.

  """
  if msg:
    output = "%s: %s" % (msg, error_desc)
  else:
    output = error_desc
  raise qa_error.Error(output)


def AssertIn(item, sequence, msg=None):
85
86
87
88
  """Raises an error when item is not in sequence.

  """
  if item not in sequence:
89
    _RaiseWithInfo(msg, "%r not in %r" % (item, sequence))
90
91


92
def AssertNotIn(item, sequence, msg=None):
93
94
95
96
  """Raises an error when item is in sequence.

  """
  if item in sequence:
97
    _RaiseWithInfo(msg, "%r in %r" % (item, sequence))
98
99


100
def AssertEqual(first, second, msg=None):
101
102
103
104
  """Raises an error when values aren't equal.

  """
  if not first == second:
105
    _RaiseWithInfo(msg, "%r == %r" % (first, second))
106
107


108
def AssertMatch(string, pattern, msg=None):
109
110
111
112
  """Raises an error when string doesn't match regexp pattern.

  """
  if not re.match(pattern, string):
113
    _RaiseWithInfo(msg, "%r doesn't match /%r/" % (string, pattern))
114
115


116
def _GetName(entity, fn):
117
118
119
  """Tries to get name of an entity.

  @type entity: string or dict
120
  @param fn: Function retrieving name from entity
121
122
123
124
125

  """
  if isinstance(entity, basestring):
    result = entity
  else:
126
    result = fn(entity)
127
128
129
130
131
132
133

  if not ht.TNonEmptyString(result):
    raise Exception("Invalid name '%s'" % result)

  return result


134
135
136
137
138
139
140
141
142
143
144
145
def _AssertRetCode(rcode, fail, cmdstr, nodename):
  """Check the return value from a command and possibly raise an exception.

  """
  if fail and rcode == 0:
    raise qa_error.Error("Command '%s' on node %s was expected to fail but"
                         " didn't" % (cmdstr, nodename))
  elif not fail and rcode != 0:
    raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
                         (cmdstr, nodename, rcode))


146
def AssertCommand(cmd, fail=False, node=None, log_cmd=True, max_seconds=None):
Iustin Pop's avatar
Iustin Pop committed
147
148
149
150
151
152
153
154
155
  """Checks that a remote command succeeds.

  @param cmd: either a string (the command to execute) or a list (to
      be converted using L{utils.ShellQuoteArgs} into a string)
  @type fail: boolean
  @param fail: if the command is expected to fail instead of succeeding
  @param node: if passed, it should be the node on which the command
      should be executed, instead of the master node (can be either a
      dict or a string)
156
157
  @param log_cmd: if False, the command won't be logged (simply passed to
      StartSSH)
158
159
160
  @type max_seconds: double
  @param max_seconds: fail if the command takes more than C{max_seconds}
      seconds
161
162
  @return: the return code of the command
  @raise qa_error.Error: if the command fails when it shouldn't or vice versa
Iustin Pop's avatar
Iustin Pop committed
163
164
165
166
167

  """
  if node is None:
    node = qa_config.GetMasterNode()

168
  nodename = _GetName(node, operator.attrgetter("primary"))
Iustin Pop's avatar
Iustin Pop committed
169
170
171
172
173
174

  if isinstance(cmd, basestring):
    cmdstr = cmd
  else:
    cmdstr = utils.ShellQuoteArgs(cmd)

175
  start = datetime.datetime.now()
176
  rcode = StartSSH(nodename, cmdstr, log_cmd=log_cmd).wait()
177
  duration_seconds = TimedeltaToTotalSeconds(datetime.datetime.now() - start)
178
  _AssertRetCode(rcode, fail, cmdstr, nodename)
Iustin Pop's avatar
Iustin Pop committed
179

180
181
182
183
184
185
  if max_seconds is not None:
    if duration_seconds > max_seconds:
      raise qa_error.Error(
        "Cmd '%s' took %f seconds, maximum of %f was exceeded" %
        (cmdstr, duration_seconds, max_seconds))

186
187
  return rcode

Iustin Pop's avatar
Iustin Pop committed
188

189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def AssertRedirectedCommand(cmd, fail=False, node=None, log_cmd=True):
  """Executes a command with redirected output.

  The log will go to the qa-output log file in the ganeti log
  directory on the node where the command is executed. The fail and
  node parameters are passed unchanged to AssertCommand.

  @param cmd: the command to be executed, as a list; a string is not
      supported

  """
  if not isinstance(cmd, list):
    raise qa_error.Error("Non-list passed to AssertRedirectedCommand")
  ofile = utils.ShellQuote(_QA_OUTPUT)
  cmdstr = utils.ShellQuoteArgs(cmd)
  AssertCommand("echo ---- $(date) %s ---- >> %s" % (cmdstr, ofile),
                fail=False, node=node, log_cmd=False)
  return AssertCommand(cmdstr + " >> %s" % ofile,
                       fail=fail, node=node, log_cmd=log_cmd)


210
211
def GetSSHCommand(node, cmd, strict=True, opts=None, tty=None,
                  use_multiplexer=True):
212
213
  """Builds SSH command to be executed.

214
215
216
  @type node: string
  @param node: node the command should run on
  @type cmd: string
217
218
  @param cmd: command to be executed in the node; if None or empty
      string, no command will be executed
219
220
  @type strict: boolean
  @param strict: whether to enable strict host key checking
221
222
  @type opts: list
  @param opts: list of additional options
Iustin Pop's avatar
Iustin Pop committed
223
224
  @type tty: boolean or None
  @param tty: if we should use tty; if None, will be auto-detected
225
226
  @type use_multiplexer: boolean
  @param use_multiplexer: if the multiplexer for the node should be used
227

228
  """
229
  args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
230

Iustin Pop's avatar
Iustin Pop committed
231
232
233
  if tty is None:
    tty = sys.stdout.isatty()

234
235
  if tty:
    args.append("-t")
236
237

  if strict:
Iustin Pop's avatar
Iustin Pop committed
238
    tmp = "yes"
239
  else:
Iustin Pop's avatar
Iustin Pop committed
240
241
242
243
    tmp = "no"
  args.append("-oStrictHostKeyChecking=%s" % tmp)
  args.append("-oClearAllForwardings=yes")
  args.append("-oForwardAgent=yes")
244
245
  if opts:
    args.extend(opts)
246
  if node in _MULTIPLEXERS and use_multiplexer:
247
    spath = _MULTIPLEXERS[node][0]
Iustin Pop's avatar
Iustin Pop committed
248
249
    args.append("-oControlPath=%s" % spath)
    args.append("-oControlMaster=no")
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268

  (vcluster_master, vcluster_basedir) = \
    qa_config.GetVclusterSettings()

  if vcluster_master:
    args.append(vcluster_master)
    args.append("%s/%s/cmd" % (vcluster_basedir, node))

    if cmd:
      # For virtual clusters the whole command must be wrapped using the "cmd"
      # script, as that script sets a number of environment variables. If the
      # command contains shell meta characters the whole command needs to be
      # quoted.
      args.append(utils.ShellQuote(cmd))
  else:
    args.append(node)

    if cmd:
      args.append(cmd)
269
270
271
272

  return args


273
def StartLocalCommand(cmd, _nolog_opts=False, log_cmd=True, **kwargs):
274
275
276
  """Starts a local command.

  """
277
278
279
280
281
  if log_cmd:
    if _nolog_opts:
      pcmd = [i for i in cmd if not i.startswith("-")]
    else:
      pcmd = cmd
282
283
    print "%s %s" % (colors.colorize("Command:", colors.CYAN),
                     utils.ShellQuoteArgs(pcmd))
284
285
286
  return subprocess.Popen(cmd, shell=False, **kwargs)


287
def StartSSH(node, cmd, strict=True, log_cmd=True):
288
289
290
  """Starts SSH.

  """
291
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
292
                           _nolog_opts=True, log_cmd=log_cmd)
293
294


295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def StartMultiplexer(node):
  """Starts a multiplexer command.

  @param node: the node for which to open the multiplexer

  """
  if node in _MULTIPLEXERS:
    return

  # Note: yes, we only need mktemp, since we'll remove the file anyway
  sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
  utils.RemoveFile(sname)
  opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
  print "Created socket at %s" % sname
  child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
  _MULTIPLEXERS[node] = (sname, child)


def CloseMultiplexers():
  """Closes all current multiplexers and cleans up.

  """
  for node in _MULTIPLEXERS.keys():
    (sname, child) = _MULTIPLEXERS.pop(node)
    utils.KillProcess(child.pid, timeout=10, waitpid=True)
    utils.RemoveFile(sname)


323
def GetCommandOutput(node, cmd, tty=None, use_multiplexer=True, log_cmd=True,
324
                     fail=False):
325
326
  """Returns the output of a command executed on the given node.

327
328
329
330
331
332
  @type node: string
  @param node: node the command should run on
  @type cmd: string
  @param cmd: command to be executed in the node (cannot be empty or None)
  @type tty: bool or None
  @param tty: if we should use tty; if None, it will be auto-detected
333
334
335
  @type use_multiplexer: bool
  @param use_multiplexer: if the SSH multiplexer provided by the QA should be
                          used or not
336
337
  @type log_cmd: bool
  @param log_cmd: if the command should be logged
338
339
  @type fail: bool
  @param fail: whether the command is expected to fail
340
  """
341
  assert cmd
342
343
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty,
                                      use_multiplexer=use_multiplexer),
344
                        stdout=subprocess.PIPE, log_cmd=log_cmd)
345
  rcode = p.wait()
346
  _AssertRetCode(rcode, fail, cmd, node)
347
  return p.stdout.read()
348
349


350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def GetObjectInfo(infocmd):
  """Get and parse information about a Ganeti object.

  @type infocmd: list of strings
  @param infocmd: command to be executed, e.g. ["gnt-cluster", "info"]
  @return: the information parsed, appropriately stored in dictionaries,
      lists...

  """
  master = qa_config.GetMasterNode()
  cmdline = utils.ShellQuoteArgs(infocmd)
  info_out = GetCommandOutput(master.primary, cmdline)
  return yaml.load(info_out)


365
366
367
368
369
def UploadFile(node, src):
  """Uploads a file to a node and returns the filename.

  Caller needs to remove the returned file on the node when it's not needed
  anymore.
370

371
372
373
374
  """
  # Make sure nobody else has access to it while preserving local permissions
  mode = os.stat(src).st_mode & 0700

375
376
  cmd = ('tmp=$(mktemp --tmpdir gnt.XXXXXX) && '
         'chmod %o "${tmp}" && '
377
378
379
380
         '[[ -f "${tmp}" ]] && '
         'cat > "${tmp}" && '
         'echo "${tmp}"') % mode

Iustin Pop's avatar
Iustin Pop committed
381
  f = open(src, "r")
382
383
384
385
386
387
388
389
390
  try:
    p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
                         stdout=subprocess.PIPE)
    AssertEqual(p.wait(), 0)

    # Return temporary filename
    return p.stdout.read().strip()
  finally:
    f.close()
Michael Hanselmann's avatar
Michael Hanselmann committed
391
392


393
394
395
396
397
398
399
400
401
402
def UploadData(node, data, mode=0600, filename=None):
  """Uploads data to a node and returns the filename.

  Caller needs to remove the returned file on the node when it's not needed
  anymore.

  """
  if filename:
    tmp = "tmp=%s" % utils.ShellQuote(filename)
  else:
403
404
    tmp = ('tmp=$(mktemp --tmpdir gnt.XXXXXX) && '
           'chmod %o "${tmp}"') % mode
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
  cmd = ("%s && "
         "[[ -f \"${tmp}\" ]] && "
         "cat > \"${tmp}\" && "
         "echo \"${tmp}\"") % tmp

  p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
                       stdin=subprocess.PIPE, stdout=subprocess.PIPE)
  p.stdin.write(data)
  p.stdin.close()
  AssertEqual(p.wait(), 0)

  # Return temporary filename
  return p.stdout.read().strip()


420
421
422
423
424
425
426
def BackupFile(node, path):
  """Creates a backup of a file on the node and returns the filename.

  Caller needs to remove the returned file on the node when it's not needed
  anymore.

  """
427
428
  vpath = MakeNodePath(node, path)

429
  cmd = ("tmp=$(mktemp .gnt.XXXXXX --tmpdir=$(dirname %s)) && "
430
431
         "[[ -f \"$tmp\" ]] && "
         "cp %s $tmp && "
432
         "echo $tmp") % (utils.ShellQuote(vpath), utils.ShellQuote(vpath))
433
434

  # Return temporary filename
435
436
437
438
439
  result = GetCommandOutput(node, cmd).strip()

  print "Backup filename: %s" % result

  return result
440
441


Michael Hanselmann's avatar
Michael Hanselmann committed
442
443
444
def ResolveInstanceName(instance):
  """Gets the full name of an instance.

445
446
447
  @type instance: string
  @param instance: Instance name

448
  """
449
450
  info = GetObjectInfo(["gnt-instance", "info", instance])
  return info[0]["Instance name"]
451
452
453
454
455
456


def ResolveNodeName(node):
  """Gets the full name of a node.

  """
457
458
  info = GetObjectInfo(["gnt-node", "info", node.primary])
  return info[0]["Node name"]
459
460
461
462
463


def GetNodeInstances(node, secondaries=False):
  """Gets a list of instances on a node.

Michael Hanselmann's avatar
Michael Hanselmann committed
464
465
  """
  master = qa_config.GetMasterNode()
466
  node_name = ResolveNodeName(node)
Michael Hanselmann's avatar
Michael Hanselmann committed
467

468
  # Get list of all instances
Iustin Pop's avatar
Iustin Pop committed
469
470
  cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
         "--output=name,pnode,snodes"]
471
  output = GetCommandOutput(master.primary, utils.ShellQuoteArgs(cmd))
472
473
474

  instances = []
  for line in output.splitlines():
Iustin Pop's avatar
Iustin Pop committed
475
    (name, pnode, snodes) = line.split(":", 2)
476
    if ((not secondaries and pnode == node_name) or
Iustin Pop's avatar
Iustin Pop committed
477
        (secondaries and node_name in snodes.split(","))):
478
      instances.append(name)
Michael Hanselmann's avatar
Michael Hanselmann committed
479

480
  return instances
Michael Hanselmann's avatar
Michael Hanselmann committed
481
482


483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def _SelectQueryFields(rnd, fields):
  """Generates a list of fields for query tests.

  """
  # Create copy for shuffling
  fields = list(fields)
  rnd.shuffle(fields)

  # Check all fields
  yield fields
  yield sorted(fields)

  # Duplicate fields
  yield fields + fields

  # Check small groups of fields
  while fields:
    yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]


def _List(listcmd, fields, names):
  """Runs a list command.

  """
  master = qa_config.GetMasterNode()

509
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
510
511
512
513
514
         "--output", ",".join(fields)]

  if names:
    cmd.extend(names)

515
  return GetCommandOutput(master.primary,
516
517
518
                          utils.ShellQuoteArgs(cmd)).splitlines()


519
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
520
521
522
523
524
525
526
527
  """Runs a number of tests on query commands.

  @param cmd: Command name
  @param fields: List of field names

  """
  rnd = random.Random(hash(cmd))

Iustin Pop's avatar
Iustin Pop committed
528
  fields = list(fields)
529
530
531
532
  rnd.shuffle(fields)

  # Test a number of field combinations
  for testfields in _SelectQueryFields(rnd, fields):
533
    AssertRedirectedCommand([cmd, "list", "--output", ",".join(testfields)])
534

535
536
  if namefield is not None:
    namelist_fn = compat.partial(_List, cmd, [namefield])
537

538
539
540
    # When no names were requested, the list must be sorted
    names = namelist_fn(None)
    AssertEqual(names, utils.NiceSort(names))
541

542
543
544
    # When requesting specific names, the order must be kept
    revnames = list(reversed(names))
    AssertEqual(namelist_fn(revnames), revnames)
545

546
547
548
    randnames = list(names)
    rnd.shuffle(randnames)
    AssertEqual(namelist_fn(randnames), randnames)
549

550
551
552
553
  if test_unknown:
    # Listing unknown items must fail
    AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
                  fail=True)
554
555

  # Check exit code for listing unknown field
556
557
558
  AssertEqual(AssertRedirectedCommand([cmd, "list",
                                       "--output=field/does/not/exist"],
                                      fail=True),
559
560
561
562
563
564
565
              constants.EXIT_UNKNOWN_FIELD)


def GenericQueryFieldsTest(cmd, fields):
  master = qa_config.GetMasterNode()

  # Listing fields
566
567
  AssertRedirectedCommand([cmd, "list-fields"])
  AssertRedirectedCommand([cmd, "list-fields"] + fields)
568
569
570

  # Check listed fields (all, must be sorted)
  realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
571
  output = GetCommandOutput(master.primary,
572
573
                            utils.ShellQuoteArgs(realcmd)).splitlines()
  AssertEqual([line.split("|", 1)[0] for line in output],
574
              utils.NiceSort(fields))
575
576
577
578
579
580

  # Check exit code for listing unknown field
  AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
                            fail=True),
              constants.EXIT_UNKNOWN_FIELD)

581

582
583
584
585
586
587
588
def AddToEtcHosts(hostnames):
  """Adds hostnames to /etc/hosts.

  @param hostnames: List of hostnames first used A records, all other CNAMEs

  """
  master = qa_config.GetMasterNode()
589
  tmp_hosts = UploadData(master.primary, "", mode=0644)
590
591
592
593
594
595

  data = []
  for localhost in ("::1", "127.0.0.1"):
    data.append("%s %s" % (localhost, " ".join(hostnames)))

  try:
596
597
598
599
600
601
602
603
604
    AssertCommand("{ cat %s && echo -e '%s'; } > %s && mv %s %s" %
                  (utils.ShellQuote(pathutils.ETC_HOSTS),
                   "\\n".join(data),
                   utils.ShellQuote(tmp_hosts),
                   utils.ShellQuote(tmp_hosts),
                   utils.ShellQuote(pathutils.ETC_HOSTS)))
  except Exception:
    AssertCommand(["rm", "-f", tmp_hosts])
    raise
605
606
607
608
609
610
611
612
613


def RemoveFromEtcHosts(hostnames):
  """Remove hostnames from /etc/hosts.

  @param hostnames: List of hostnames first used A records, all other CNAMEs

  """
  master = qa_config.GetMasterNode()
614
  tmp_hosts = UploadData(master.primary, "", mode=0644)
615
616
617
618
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)

  sed_data = " ".join(hostnames)
  try:
619
620
    AssertCommand((r"sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' %s > %s"
                   r" && mv %s %s") %
621
622
623
624
625
626
                   (sed_data, utils.ShellQuote(pathutils.ETC_HOSTS),
                    quoted_tmp_hosts, quoted_tmp_hosts,
                    utils.ShellQuote(pathutils.ETC_HOSTS)))
  except Exception:
    AssertCommand(["rm", "-f", tmp_hosts])
    raise
627
628
629
630
631
632


def RunInstanceCheck(instance, running):
  """Check if instance is running or not.

  """
633
  instance_name = _GetName(instance, operator.attrgetter("name"))
634

635
636
637
638
639
640
641
  script = qa_config.GetInstanceCheckScript()
  if not script:
    return

  master_node = qa_config.GetMasterNode()

  # Build command to connect to master node
642
  master_ssh = GetSSHCommand(master_node.primary, "--")
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707

  if running:
    running_shellval = "1"
    running_text = ""
  else:
    running_shellval = ""
    running_text = "not "

  print FormatInfo("Checking if instance '%s' is %srunning" %
                   (instance_name, running_text))

  args = [script, instance_name]
  env = {
    "PATH": constants.HOOKS_PATH,
    "RUN_UUID": _RUN_UUID,
    "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
    "INSTANCE_NAME": instance_name,
    "INSTANCE_RUNNING": running_shellval,
    }

  result = os.spawnve(os.P_WAIT, script, args, env)
  if result != 0:
    raise qa_error.Error("Instance check failed with result %s" % result)


def _InstanceCheckInner(expected, instarg, args, result):
  """Helper function used by L{InstanceCheck}.

  """
  if instarg == FIRST_ARG:
    instance = args[0]
  elif instarg == RETURN_VALUE:
    instance = result
  else:
    raise Exception("Invalid value '%s' for instance argument" % instarg)

  if expected in (INST_DOWN, INST_UP):
    RunInstanceCheck(instance, (expected == INST_UP))
  elif expected is not None:
    raise Exception("Invalid value '%s'" % expected)


def InstanceCheck(before, after, instarg):
  """Decorator to check instance status before and after test.

  @param before: L{INST_DOWN} if instance must be stopped before test,
    L{INST_UP} if instance must be running before test, L{None} to not check.
  @param after: L{INST_DOWN} if instance must be stopped after test,
    L{INST_UP} if instance must be running after test, L{None} to not check.
  @param instarg: L{FIRST_ARG} to use first argument to test as instance (a
    dictionary), L{RETURN_VALUE} to use return value (disallows pre-checks)

  """
  def decorator(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
      _InstanceCheckInner(before, instarg, args, NotImplemented)

      result = fn(*args, **kwargs)

      _InstanceCheckInner(after, instarg, args, result)

      return result
    return wrapper
  return decorator
708
709
710
711
712
713


def GetNonexistentGroups(count):
  """Gets group names which shouldn't exist on the cluster.

  @param count: Number of groups to get
Helga Velroyen's avatar
Helga Velroyen committed
714
  @rtype: integer
715
716

  """
Helga Velroyen's avatar
Helga Velroyen committed
717
  return GetNonexistentEntityNames(count, "groups", "group")
718

Helga Velroyen's avatar
Helga Velroyen committed
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740

def GetNonexistentEntityNames(count, name_config, name_prefix):
  """Gets entity names which shouldn't exist on the cluster.

  The actualy names can refer to arbitrary entities (for example
  groups, networks).

  @param count: Number of names to get
  @rtype: integer
  @param name_config: name of the leaf in the config containing
    this entity's configuration, including a 'inexistent-'
    element
  @rtype: string
  @param name_prefix: prefix of the entity's names, used to compose
    the default values; for example for groups, the prefix is
    'group' and the generated names are then group1, group2, ...
  @rtype: string

  """
  entities = qa_config.get(name_config, {})

  default = [name_prefix + str(i) for i in range(count)]
741
742
  assert count <= len(default)

Helga Velroyen's avatar
Helga Velroyen committed
743
744
  name_config_inexistent = "inexistent-" + name_config
  candidates = entities.get(name_config_inexistent, default)[:count]
745
746

  if len(candidates) < count:
Helga Velroyen's avatar
Helga Velroyen committed
747
748
    raise Exception("At least %s non-existent %s are needed" %
                    (count, name_config))
749
750

  return candidates
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773


def MakeNodePath(node, path):
  """Builds an absolute path for a virtual node.

  @type node: string or L{qa_config._QaNode}
  @param node: Node
  @type path: string
  @param path: Path without node-specific prefix

  """
  (_, basedir) = qa_config.GetVclusterSettings()

  if isinstance(node, basestring):
    name = node
  else:
    name = node.primary

  if basedir:
    assert path.startswith("/")
    return "%s%s" % (vcluster.MakeNodeRoot(basedir, name), path)
  else:
    return path
774
775


776
def _GetParameterOptions(specs):
777
  """Helper to build policy options."""
778
779
  values = ["%s=%s" % (par, val)
            for (par, val) in specs.items()]
780
781
782
  return ",".join(values)


783
784
def TestSetISpecs(new_specs=None, diff_specs=None, get_policy_fn=None,
                  build_cmd_fn=None, fail=False, old_values=None):
785
786
  """Change instance specs for an object.

787
788
789
790
791
792
  At most one of new_specs or diff_specs can be specified.

  @type new_specs: dict
  @param new_specs: new complete specs, in the same format returned by
      L{ParseIPolicy}.
  @type diff_specs: dict
793
794
795
  @param diff_specs: partial specs, it can be an incomplete specifications, but
      if min/max specs are specified, their number must match the number of the
      existing specs
796
797
  @type get_policy_fn: function
  @param get_policy_fn: function that returns the current policy as in
798
      L{ParseIPolicy}
799
800
801
802
803
804
805
  @type build_cmd_fn: function
  @param build_cmd_fn: function that return the full command line from the
      options alone
  @type fail: bool
  @param fail: if the change is expected to fail
  @type old_values: tuple
  @param old_values: (old_policy, old_specs), as returned by
806
807
     L{ParseIPolicy}
  @return: same as L{ParseIPolicy}
808
809
810
811

  """
  assert get_policy_fn is not None
  assert build_cmd_fn is not None
812
  assert new_specs is None or diff_specs is None
813
814
815
816
817

  if old_values:
    (old_policy, old_specs) = old_values
  else:
    (old_policy, old_specs) = get_policy_fn()
818
819
820

  if diff_specs:
    new_specs = copy.deepcopy(old_specs)
821
822
823
824
825
826
827
828
829
830
    if constants.ISPECS_MINMAX in diff_specs:
      AssertEqual(len(new_specs[constants.ISPECS_MINMAX]),
                  len(diff_specs[constants.ISPECS_MINMAX]))
      for (new_minmax, diff_minmax) in zip(new_specs[constants.ISPECS_MINMAX],
                                           diff_specs[constants.ISPECS_MINMAX]):
        for (key, parvals) in diff_minmax.items():
          for (par, val) in parvals.items():
            new_minmax[key][par] = val
    for (par, val) in diff_specs.get(constants.ISPECS_STD, {}).items():
      new_specs[constants.ISPECS_STD][par] = val
831

832
833
  if new_specs:
    cmd = []
834
    if (diff_specs is None or constants.ISPECS_MINMAX in diff_specs):
835
      minmax_opt_items = []
836
837
838
839
840
841
      for minmax in new_specs[constants.ISPECS_MINMAX]:
        minmax_opts = []
        for key in ["min", "max"]:
          keyopt = _GetParameterOptions(minmax[key])
          minmax_opts.append("%s:%s" % (key, keyopt))
        minmax_opt_items.append("/".join(minmax_opts))
842
843
      cmd.extend([
        "--ipolicy-bounds-specs",
844
        "//".join(minmax_opt_items)
845
        ])
846
    if diff_specs is None:
847
      std_source = new_specs
848
849
    else:
      std_source = diff_specs
850
    std_opt = _GetParameterOptions(std_source.get("std", {}))
851
852
853
854
    if std_opt:
      cmd.extend(["--ipolicy-std-specs", std_opt])
    AssertCommand(build_cmd_fn(cmd), fail=fail)

855
856
857
858
859
860
861
862
    # Check the new state
    (eff_policy, eff_specs) = get_policy_fn()
    AssertEqual(eff_policy, old_policy)
    if fail:
      AssertEqual(eff_specs, old_specs)
    else:
      AssertEqual(eff_specs, new_specs)

863
  else:
864
865
    (eff_policy, eff_specs) = (old_policy, old_specs)

866
867
868
869
870
871
872
873
874
875
876
  return (eff_policy, eff_specs)


def ParseIPolicy(policy):
  """Parse and split instance an instance policy.

  @type policy: dict
  @param policy: policy, as returned by L{GetObjectInfo}
  @rtype: tuple
  @return: (policy, specs), where:
      - policy is a dictionary of the policy values, instance specs excluded
877
878
      - specs is a dictionary containing only the specs, using the internal
        format (see L{constants.IPOLICY_DEFAULTS} for an example)
879
880
881
882
883

  """
  ret_specs = {}
  ret_policy = {}
  for (key, val) in policy.items():
884
885
886
887
888
889
890
891
892
893
    if key == "bounds specs":
      ret_specs[constants.ISPECS_MINMAX] = []
      for minmax in val:
        ret_minmax = {}
        for key in minmax:
          keyparts = key.split("/", 1)
          assert len(keyparts) > 1
          ret_minmax[keyparts[0]] = minmax[key]
        ret_specs[constants.ISPECS_MINMAX].append(ret_minmax)
    elif key == constants.ISPECS_STD:
894
      ret_specs[key] = val
895
896
897
    else:
      ret_policy[key] = val
  return (ret_policy, ret_specs)
898
899
900
901
902
903
904


def UsesIPv6Connection(host, port):
  """Returns True if the connection to a given host/port could go through IPv6.

  """
  return any(t[0] == socket.AF_INET6 for t in socket.getaddrinfo(host, port))
905
906


907
908
909
910
911
912
913
914
915
916
917
918
919
920
def TimedeltaToTotalSeconds(td):
  """Returns the total seconds in a C{datetime.timedelta} object.

  This performs the same task as the C{datetime.timedelta.total_seconds()}
  method which is present in Python 2.7 onwards.

  @type td: datetime.timedelta
  @param td: timedelta object to convert
  @rtype float
  @return: total seconds in the timedelta object

  """
  return ((td.microseconds + (td.seconds + td.days * 24.0 * 3600.0) * 10 ** 6) /
          10 ** 6)