Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ ea7693c1

History | View | Annotate | Download (18.1 kB)

1
#
2
#
3

    
4
# Copyright (C) 2007, 2011, 2012 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
"""Utilities for QA tests.
23

24
"""
25

    
26
import os
27
import re
28
import sys
29
import subprocess
30
import random
31
import tempfile
32

    
33
try:
34
  import functools
35
except ImportError, err:
36
  raise ImportError("Python 2.5 or higher is required: %s" % err)
37

    
38
from ganeti import utils
39
from ganeti import compat
40
from ganeti import constants
41
from ganeti import ht
42
from ganeti import pathutils
43

    
44
import qa_config
45
import qa_error
46

    
47

    
48
_INFO_SEQ = None
49
_WARNING_SEQ = None
50
_ERROR_SEQ = None
51
_RESET_SEQ = None
52

    
53
_MULTIPLEXERS = {}
54

    
55
#: Unique ID per QA run
56
_RUN_UUID = utils.NewUUID()
57

    
58

    
59
(INST_DOWN,
60
 INST_UP) = range(500, 502)
61

    
62
(FIRST_ARG,
63
 RETURN_VALUE) = range(1000, 1002)
64

    
65

    
66
def _SetupColours():
67
  """Initializes the colour constants.
68

69
  """
70
  # pylint: disable=W0603
71
  # due to global usage
72
  global _INFO_SEQ, _WARNING_SEQ, _ERROR_SEQ, _RESET_SEQ
73

    
74
  # Don't use colours if stdout isn't a terminal
75
  if not sys.stdout.isatty():
76
    return
77

    
78
  try:
79
    import curses
80
  except ImportError:
81
    # Don't use colours if curses module can't be imported
82
    return
83

    
84
  curses.setupterm()
85

    
86
  _RESET_SEQ = curses.tigetstr("op")
87

    
88
  setaf = curses.tigetstr("setaf")
89
  _INFO_SEQ = curses.tparm(setaf, curses.COLOR_GREEN)
90
  _WARNING_SEQ = curses.tparm(setaf, curses.COLOR_YELLOW)
91
  _ERROR_SEQ = curses.tparm(setaf, curses.COLOR_RED)
92

    
93

    
94
_SetupColours()
95

    
96

    
97
def AssertIn(item, sequence):
98
  """Raises an error when item is not in sequence.
99

100
  """
101
  if item not in sequence:
102
    raise qa_error.Error("%r not in %r" % (item, sequence))
103

    
104

    
105
def AssertNotIn(item, sequence):
106
  """Raises an error when item is in sequence.
107

108
  """
109
  if item in sequence:
110
    raise qa_error.Error("%r in %r" % (item, sequence))
111

    
112

    
113
def AssertEqual(first, second):
114
  """Raises an error when values aren't equal.
115

116
  """
117
  if not first == second:
118
    raise qa_error.Error("%r == %r" % (first, second))
119

    
120

    
121
def AssertMatch(string, pattern):
122
  """Raises an error when string doesn't match regexp pattern.
123

124
  """
125
  if not re.match(pattern, string):
126
    raise qa_error.Error("%r doesn't match /%r/" % (string, pattern))
127

    
128

    
129
def _GetName(entity, key):
130
  """Tries to get name of an entity.
131

132
  @type entity: string or dict
133
  @type key: string
134
  @param key: Dictionary key containing name
135

136
  """
137
  if isinstance(entity, basestring):
138
    result = entity
139
  elif isinstance(entity, dict):
140
    result = entity[key]
141
  else:
142
    raise qa_error.Error("Expected string or dictionary, got %s: %s" %
143
                         (type(entity), entity))
144

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

    
148
  return result
149

    
150

    
151
def AssertCommand(cmd, fail=False, node=None):
152
  """Checks that a remote command succeeds.
153

154
  @param cmd: either a string (the command to execute) or a list (to
155
      be converted using L{utils.ShellQuoteArgs} into a string)
156
  @type fail: boolean
157
  @param fail: if the command is expected to fail instead of succeeding
158
  @param node: if passed, it should be the node on which the command
159
      should be executed, instead of the master node (can be either a
160
      dict or a string)
161
  @return: the return code of the command
162
  @raise qa_error.Error: if the command fails when it shouldn't or vice versa
163

164
  """
165
  if node is None:
166
    node = qa_config.GetMasterNode()
167

    
168
  nodename = _GetName(node, "primary")
169

    
170
  if isinstance(cmd, basestring):
171
    cmdstr = cmd
172
  else:
173
    cmdstr = utils.ShellQuoteArgs(cmd)
174

    
175
  rcode = StartSSH(nodename, cmdstr).wait()
176

    
177
  if fail:
178
    if rcode == 0:
179
      raise qa_error.Error("Command '%s' on node %s was expected to fail but"
180
                           " didn't" % (cmdstr, nodename))
181
  else:
182
    if rcode != 0:
183
      raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
184
                           (cmdstr, nodename, rcode))
185

    
186
  return rcode
187

    
188

    
189
def GetSSHCommand(node, cmd, strict=True, opts=None, tty=None):
190
  """Builds SSH command to be executed.
191

192
  @type node: string
193
  @param node: node the command should run on
194
  @type cmd: string
195
  @param cmd: command to be executed in the node; if None or empty
196
      string, no command will be executed
197
  @type strict: boolean
198
  @param strict: whether to enable strict host key checking
199
  @type opts: list
200
  @param opts: list of additional options
201
  @type tty: boolean or None
202
  @param tty: if we should use tty; if None, will be auto-detected
203

204
  """
205
  args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
206

    
207
  if tty is None:
208
    tty = sys.stdout.isatty()
209

    
210
  if tty:
211
    args.append("-t")
212

    
213
  if strict:
214
    tmp = "yes"
215
  else:
216
    tmp = "no"
217
  args.append("-oStrictHostKeyChecking=%s" % tmp)
218
  args.append("-oClearAllForwardings=yes")
219
  args.append("-oForwardAgent=yes")
220
  if opts:
221
    args.extend(opts)
222
  if node in _MULTIPLEXERS:
223
    spath = _MULTIPLEXERS[node][0]
224
    args.append("-oControlPath=%s" % spath)
225
    args.append("-oControlMaster=no")
226
  args.append(node)
227
  if cmd:
228
    args.append(cmd)
229

    
230
  return args
231

    
232

    
233
def StartLocalCommand(cmd, _nolog_opts=False, **kwargs):
234
  """Starts a local command.
235

236
  """
237
  if _nolog_opts:
238
    pcmd = [i for i in cmd if not i.startswith("-")]
239
  else:
240
    pcmd = cmd
241
  print "Command: %s" % utils.ShellQuoteArgs(pcmd)
242
  return subprocess.Popen(cmd, shell=False, **kwargs)
243

    
244

    
245
def StartSSH(node, cmd, strict=True):
246
  """Starts SSH.
247

248
  """
249
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
250
                           _nolog_opts=True)
251

    
252

    
253
def StartMultiplexer(node):
254
  """Starts a multiplexer command.
255

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

258
  """
259
  if node in _MULTIPLEXERS:
260
    return
261

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

    
270

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

274
  """
275
  for node in _MULTIPLEXERS.keys():
276
    (sname, child) = _MULTIPLEXERS.pop(node)
277
    utils.KillProcess(child.pid, timeout=10, waitpid=True)
278
    utils.RemoveFile(sname)
279

    
280

    
281
def GetCommandOutput(node, cmd, tty=None):
282
  """Returns the output of a command executed on the given node.
283

284
  """
285
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
286
                        stdout=subprocess.PIPE)
287
  AssertEqual(p.wait(), 0)
288
  return p.stdout.read()
289

    
290

    
291
def UploadFile(node, src):
292
  """Uploads a file to a node and returns the filename.
293

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

297
  """
298
  # Make sure nobody else has access to it while preserving local permissions
299
  mode = os.stat(src).st_mode & 0700
300

    
301
  cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
302
         '[[ -f "${tmp}" ]] && '
303
         'cat > "${tmp}" && '
304
         'echo "${tmp}"') % mode
305

    
306
  f = open(src, "r")
307
  try:
308
    p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
309
                         stdout=subprocess.PIPE)
310
    AssertEqual(p.wait(), 0)
311

    
312
    # Return temporary filename
313
    return p.stdout.read().strip()
314
  finally:
315
    f.close()
316

    
317

    
318
def UploadData(node, data, mode=0600, filename=None):
319
  """Uploads data to a node and returns the filename.
320

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

324
  """
325
  if filename:
326
    tmp = "tmp=%s" % utils.ShellQuote(filename)
327
  else:
328
    tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
329
  cmd = ("%s && "
330
         "[[ -f \"${tmp}\" ]] && "
331
         "cat > \"${tmp}\" && "
332
         "echo \"${tmp}\"") % tmp
333

    
334
  p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
335
                       stdin=subprocess.PIPE, stdout=subprocess.PIPE)
336
  p.stdin.write(data)
337
  p.stdin.close()
338
  AssertEqual(p.wait(), 0)
339

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

    
343

    
344
def BackupFile(node, path):
345
  """Creates a backup of a file on the node and returns the filename.
346

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

350
  """
351
  cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
352
         "[[ -f \"$tmp\" ]] && "
353
         "cp %s $tmp && "
354
         "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
355

    
356
  # Return temporary filename
357
  return GetCommandOutput(node, cmd).strip()
358

    
359

    
360
def _ResolveName(cmd, key):
361
  """Helper function.
362

363
  """
364
  master = qa_config.GetMasterNode()
365

    
366
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
367
  for line in output.splitlines():
368
    (lkey, lvalue) = line.split(":", 1)
369
    if lkey == key:
370
      return lvalue.lstrip()
371
  raise KeyError("Key not found")
372

    
373

    
374
def ResolveInstanceName(instance):
375
  """Gets the full name of an instance.
376

377
  @type instance: string
378
  @param instance: Instance name
379

380
  """
381
  return _ResolveName(["gnt-instance", "info", instance],
382
                      "Instance name")
383

    
384

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

388
  """
389
  return _ResolveName(["gnt-node", "info", node["primary"]],
390
                      "Node name")
391

    
392

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

396
  """
397
  master = qa_config.GetMasterNode()
398
  node_name = ResolveNodeName(node)
399

    
400
  # Get list of all instances
401
  cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
402
         "--output=name,pnode,snodes"]
403
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
404

    
405
  instances = []
406
  for line in output.splitlines():
407
    (name, pnode, snodes) = line.split(":", 2)
408
    if ((not secondaries and pnode == node_name) or
409
        (secondaries and node_name in snodes.split(","))):
410
      instances.append(name)
411

    
412
  return instances
413

    
414

    
415
def _SelectQueryFields(rnd, fields):
416
  """Generates a list of fields for query tests.
417

418
  """
419
  # Create copy for shuffling
420
  fields = list(fields)
421
  rnd.shuffle(fields)
422

    
423
  # Check all fields
424
  yield fields
425
  yield sorted(fields)
426

    
427
  # Duplicate fields
428
  yield fields + fields
429

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

    
434

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

438
  """
439
  master = qa_config.GetMasterNode()
440

    
441
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
442
         "--output", ",".join(fields)]
443

    
444
  if names:
445
    cmd.extend(names)
446

    
447
  return GetCommandOutput(master["primary"],
448
                          utils.ShellQuoteArgs(cmd)).splitlines()
449

    
450

    
451
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
452
  """Runs a number of tests on query commands.
453

454
  @param cmd: Command name
455
  @param fields: List of field names
456

457
  """
458
  rnd = random.Random(hash(cmd))
459

    
460
  fields = list(fields)
461
  rnd.shuffle(fields)
462

    
463
  # Test a number of field combinations
464
  for testfields in _SelectQueryFields(rnd, fields):
465
    AssertCommand([cmd, "list", "--output", ",".join(testfields)])
466

    
467
  if namefield is not None:
468
    namelist_fn = compat.partial(_List, cmd, [namefield])
469

    
470
    # When no names were requested, the list must be sorted
471
    names = namelist_fn(None)
472
    AssertEqual(names, utils.NiceSort(names))
473

    
474
    # When requesting specific names, the order must be kept
475
    revnames = list(reversed(names))
476
    AssertEqual(namelist_fn(revnames), revnames)
477

    
478
    randnames = list(names)
479
    rnd.shuffle(randnames)
480
    AssertEqual(namelist_fn(randnames), randnames)
481

    
482
  if test_unknown:
483
    # Listing unknown items must fail
484
    AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
485
                  fail=True)
486

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

    
492

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

    
496
  # Listing fields
497
  AssertCommand([cmd, "list-fields"])
498
  AssertCommand([cmd, "list-fields"] + fields)
499

    
500
  # Check listed fields (all, must be sorted)
501
  realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
502
  output = GetCommandOutput(master["primary"],
503
                            utils.ShellQuoteArgs(realcmd)).splitlines()
504
  AssertEqual([line.split("|", 1)[0] for line in output],
505
              utils.NiceSort(fields))
506

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

    
512

    
513
def _FormatWithColor(text, seq):
514
  if not seq:
515
    return text
516
  return "%s%s%s" % (seq, text, _RESET_SEQ)
517

    
518

    
519
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
520
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
521
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
522

    
523

    
524
def AddToEtcHosts(hostnames):
525
  """Adds hostnames to /etc/hosts.
526

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

529
  """
530
  master = qa_config.GetMasterNode()
531
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
532

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

    
537
  try:
538
    AssertCommand("{ cat %s && echo -e '%s'; } > %s && mv %s %s" %
539
                  (utils.ShellQuote(pathutils.ETC_HOSTS),
540
                   "\\n".join(data),
541
                   utils.ShellQuote(tmp_hosts),
542
                   utils.ShellQuote(tmp_hosts),
543
                   utils.ShellQuote(pathutils.ETC_HOSTS)))
544
  except Exception:
545
    AssertCommand(["rm", "-f", tmp_hosts])
546
    raise
547

    
548

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

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

554
  """
555
  master = qa_config.GetMasterNode()
556
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
557
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
558

    
559
  sed_data = " ".join(hostnames)
560
  try:
561
    AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' %s > %s"
562
                   " && mv %s %s") %
563
                   (sed_data, utils.ShellQuote(pathutils.ETC_HOSTS),
564
                    quoted_tmp_hosts, quoted_tmp_hosts,
565
                    utils.ShellQuote(pathutils.ETC_HOSTS)))
566
  except Exception:
567
    AssertCommand(["rm", "-f", tmp_hosts])
568
    raise
569

    
570

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

574
  """
575
  instance_name = _GetName(instance, "name")
576

    
577
  script = qa_config.GetInstanceCheckScript()
578
  if not script:
579
    return
580

    
581
  master_node = qa_config.GetMasterNode()
582

    
583
  # Build command to connect to master node
584
  master_ssh = GetSSHCommand(master_node["primary"], "--")
585

    
586
  if running:
587
    running_shellval = "1"
588
    running_text = ""
589
  else:
590
    running_shellval = ""
591
    running_text = "not "
592

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

    
596
  args = [script, instance_name]
597
  env = {
598
    "PATH": constants.HOOKS_PATH,
599
    "RUN_UUID": _RUN_UUID,
600
    "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
601
    "INSTANCE_NAME": instance_name,
602
    "INSTANCE_RUNNING": running_shellval,
603
    }
604

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

    
609

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

613
  """
614
  if instarg == FIRST_ARG:
615
    instance = args[0]
616
  elif instarg == RETURN_VALUE:
617
    instance = result
618
  else:
619
    raise Exception("Invalid value '%s' for instance argument" % instarg)
620

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

    
626

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

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

637
  """
638
  def decorator(fn):
639
    @functools.wraps(fn)
640
    def wrapper(*args, **kwargs):
641
      _InstanceCheckInner(before, instarg, args, NotImplemented)
642

    
643
      result = fn(*args, **kwargs)
644

    
645
      _InstanceCheckInner(after, instarg, args, result)
646

    
647
      return result
648
    return wrapper
649
  return decorator
650

    
651

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

655
  @param count: Number of groups to get
656
  @rtype: integer
657

658
  """
659
  return GetNonexistentEntityNames(count, "groups", "group")
660

    
661

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

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

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

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

    
682
  default = [name_prefix + str(i) for i in range(count)]
683
  assert count <= len(default)
684

    
685
  name_config_inexistent = "inexistent-" + name_config
686
  candidates = entities.get(name_config_inexistent, default)[:count]
687

    
688
  if len(candidates) < count:
689
    raise Exception("At least %s non-existent %s are needed" %
690
                    (count, name_config))
691

    
692
  return candidates