Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 710bc88c

History | View | Annotate | Download (16.8 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

    
43
import qa_config
44
import qa_error
45

    
46

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

    
52
_MULTIPLEXERS = {}
53

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

    
57

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

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

    
64

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

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

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

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

    
83
  curses.setupterm()
84

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

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

    
92

    
93
_SetupColours()
94

    
95

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

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

    
103

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

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

    
111

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

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

    
119

    
120
def AssertNotEqual(first, second):
121
  """Raises an error when values are equal.
122

123
  """
124
  if not first != second:
125
    raise qa_error.Error("%r != %r" % (first, second))
126

    
127

    
128
def AssertMatch(string, pattern):
129
  """Raises an error when string doesn't match regexp pattern.
130

131
  """
132
  if not re.match(pattern, string):
133
    raise qa_error.Error("%r doesn't match /%r/" % (string, pattern))
134

    
135

    
136
def _GetName(entity, key):
137
  """Tries to get name of an entity.
138

139
  @type entity: string or dict
140
  @type key: string
141
  @param key: Dictionary key containing name
142

143
  """
144
  if isinstance(entity, basestring):
145
    result = entity
146
  elif isinstance(entity, dict):
147
    result = entity[key]
148
  else:
149
    raise qa_error.Error("Expected string or dictionary, got %s: %s" %
150
                         (type(entity), entity))
151

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

    
155
  return result
156

    
157

    
158
def AssertCommand(cmd, fail=False, node=None):
159
  """Checks that a remote command succeeds.
160

161
  @param cmd: either a string (the command to execute) or a list (to
162
      be converted using L{utils.ShellQuoteArgs} into a string)
163
  @type fail: boolean
164
  @param fail: if the command is expected to fail instead of succeeding
165
  @param node: if passed, it should be the node on which the command
166
      should be executed, instead of the master node (can be either a
167
      dict or a string)
168

169
  """
170
  if node is None:
171
    node = qa_config.GetMasterNode()
172

    
173
  nodename = _GetName(node, "primary")
174

    
175
  if isinstance(cmd, basestring):
176
    cmdstr = cmd
177
  else:
178
    cmdstr = utils.ShellQuoteArgs(cmd)
179

    
180
  rcode = StartSSH(nodename, cmdstr).wait()
181

    
182
  if fail:
183
    if rcode == 0:
184
      raise qa_error.Error("Command '%s' on node %s was expected to fail but"
185
                           " didn't" % (cmdstr, nodename))
186
  else:
187
    if rcode != 0:
188
      raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
189
                           (cmdstr, nodename, rcode))
190

    
191
  return rcode
192

    
193

    
194
def GetSSHCommand(node, cmd, strict=True, opts=None, tty=None):
195
  """Builds SSH command to be executed.
196

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

209
  """
210
  args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
211

    
212
  if tty is None:
213
    tty = sys.stdout.isatty()
214

    
215
  if tty:
216
    args.append("-t")
217

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

    
235
  return args
236

    
237

    
238
def StartLocalCommand(cmd, _nolog_opts=False, **kwargs):
239
  """Starts a local command.
240

241
  """
242
  if _nolog_opts:
243
    pcmd = [i for i in cmd if not i.startswith("-")]
244
  else:
245
    pcmd = cmd
246
  print "Command: %s" % utils.ShellQuoteArgs(pcmd)
247
  return subprocess.Popen(cmd, shell=False, **kwargs)
248

    
249

    
250
def StartSSH(node, cmd, strict=True):
251
  """Starts SSH.
252

253
  """
254
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
255
                           _nolog_opts=True)
256

    
257

    
258
def StartMultiplexer(node):
259
  """Starts a multiplexer command.
260

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

263
  """
264
  if node in _MULTIPLEXERS:
265
    return
266

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

    
275

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

279
  """
280
  for node in _MULTIPLEXERS.keys():
281
    (sname, child) = _MULTIPLEXERS.pop(node)
282
    utils.KillProcess(child.pid, timeout=10, waitpid=True)
283
    utils.RemoveFile(sname)
284

    
285

    
286
def GetCommandOutput(node, cmd, tty=None):
287
  """Returns the output of a command executed on the given node.
288

289
  """
290
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
291
                        stdout=subprocess.PIPE)
292
  AssertEqual(p.wait(), 0)
293
  return p.stdout.read()
294

    
295

    
296
def UploadFile(node, src):
297
  """Uploads a file to a node and returns the filename.
298

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

302
  """
303
  # Make sure nobody else has access to it while preserving local permissions
304
  mode = os.stat(src).st_mode & 0700
305

    
306
  cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
307
         '[[ -f "${tmp}" ]] && '
308
         'cat > "${tmp}" && '
309
         'echo "${tmp}"') % mode
310

    
311
  f = open(src, "r")
312
  try:
313
    p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
314
                         stdout=subprocess.PIPE)
315
    AssertEqual(p.wait(), 0)
316

    
317
    # Return temporary filename
318
    return p.stdout.read().strip()
319
  finally:
320
    f.close()
321

    
322

    
323
def UploadData(node, data, mode=0600, filename=None):
324
  """Uploads data to a node and returns the filename.
325

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

329
  """
330
  if filename:
331
    tmp = "tmp=%s" % utils.ShellQuote(filename)
332
  else:
333
    tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
334
  cmd = ("%s && "
335
         "[[ -f \"${tmp}\" ]] && "
336
         "cat > \"${tmp}\" && "
337
         "echo \"${tmp}\"") % tmp
338

    
339
  p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
340
                       stdin=subprocess.PIPE, stdout=subprocess.PIPE)
341
  p.stdin.write(data)
342
  p.stdin.close()
343
  AssertEqual(p.wait(), 0)
344

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

    
348

    
349
def BackupFile(node, path):
350
  """Creates a backup of a file on the node and returns the filename.
351

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

355
  """
356
  cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
357
         "[[ -f \"$tmp\" ]] && "
358
         "cp %s $tmp && "
359
         "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
360

    
361
  # Return temporary filename
362
  return GetCommandOutput(node, cmd).strip()
363

    
364

    
365
def _ResolveName(cmd, key):
366
  """Helper function.
367

368
  """
369
  master = qa_config.GetMasterNode()
370

    
371
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
372
  for line in output.splitlines():
373
    (lkey, lvalue) = line.split(":", 1)
374
    if lkey == key:
375
      return lvalue.lstrip()
376
  raise KeyError("Key not found")
377

    
378

    
379
def ResolveInstanceName(instance):
380
  """Gets the full name of an instance.
381

382
  @type instance: string
383
  @param instance: Instance name
384

385
  """
386
  return _ResolveName(["gnt-instance", "info", instance],
387
                      "Instance name")
388

    
389

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

393
  """
394
  return _ResolveName(["gnt-node", "info", node["primary"]],
395
                      "Node name")
396

    
397

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

401
  """
402
  master = qa_config.GetMasterNode()
403
  node_name = ResolveNodeName(node)
404

    
405
  # Get list of all instances
406
  cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
407
         "--output=name,pnode,snodes"]
408
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
409

    
410
  instances = []
411
  for line in output.splitlines():
412
    (name, pnode, snodes) = line.split(":", 2)
413
    if ((not secondaries and pnode == node_name) or
414
        (secondaries and node_name in snodes.split(","))):
415
      instances.append(name)
416

    
417
  return instances
418

    
419

    
420
def _SelectQueryFields(rnd, fields):
421
  """Generates a list of fields for query tests.
422

423
  """
424
  # Create copy for shuffling
425
  fields = list(fields)
426
  rnd.shuffle(fields)
427

    
428
  # Check all fields
429
  yield fields
430
  yield sorted(fields)
431

    
432
  # Duplicate fields
433
  yield fields + fields
434

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

    
439

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

443
  """
444
  master = qa_config.GetMasterNode()
445

    
446
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
447
         "--output", ",".join(fields)]
448

    
449
  if names:
450
    cmd.extend(names)
451

    
452
  return GetCommandOutput(master["primary"],
453
                          utils.ShellQuoteArgs(cmd)).splitlines()
454

    
455

    
456
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
457
  """Runs a number of tests on query commands.
458

459
  @param cmd: Command name
460
  @param fields: List of field names
461

462
  """
463
  rnd = random.Random(hash(cmd))
464

    
465
  fields = list(fields)
466
  rnd.shuffle(fields)
467

    
468
  # Test a number of field combinations
469
  for testfields in _SelectQueryFields(rnd, fields):
470
    AssertCommand([cmd, "list", "--output", ",".join(testfields)])
471

    
472
  if namefield is not None:
473
    namelist_fn = compat.partial(_List, cmd, [namefield])
474

    
475
    # When no names were requested, the list must be sorted
476
    names = namelist_fn(None)
477
    AssertEqual(names, utils.NiceSort(names))
478

    
479
    # When requesting specific names, the order must be kept
480
    revnames = list(reversed(names))
481
    AssertEqual(namelist_fn(revnames), revnames)
482

    
483
    randnames = list(names)
484
    rnd.shuffle(randnames)
485
    AssertEqual(namelist_fn(randnames), randnames)
486

    
487
  if test_unknown:
488
    # Listing unknown items must fail
489
    AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
490
                  fail=True)
491

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

    
497

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

    
501
  # Listing fields
502
  AssertCommand([cmd, "list-fields"])
503
  AssertCommand([cmd, "list-fields"] + fields)
504

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

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

    
517

    
518
def _FormatWithColor(text, seq):
519
  if not seq:
520
    return text
521
  return "%s%s%s" % (seq, text, _RESET_SEQ)
522

    
523

    
524
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
525
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
526
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
527

    
528

    
529
def AddToEtcHosts(hostnames):
530
  """Adds hostnames to /etc/hosts.
531

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

534
  """
535
  master = qa_config.GetMasterNode()
536
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
537

    
538
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
539
  data = []
540
  for localhost in ("::1", "127.0.0.1"):
541
    data.append("%s %s" % (localhost, " ".join(hostnames)))
542

    
543
  try:
544
    AssertCommand(("cat /etc/hosts > %s && echo -e '%s' >> %s && mv %s"
545
                   " /etc/hosts") % (quoted_tmp_hosts, "\\n".join(data),
546
                                     quoted_tmp_hosts, quoted_tmp_hosts))
547
  except qa_error.Error:
548
    AssertCommand(["rm", tmp_hosts])
549

    
550

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

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

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

    
561
  sed_data = " ".join(hostnames)
562
  try:
563
    AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' /etc/hosts > %s"
564
                   " && mv %s /etc/hosts") % (sed_data, quoted_tmp_hosts,
565
                                              quoted_tmp_hosts))
566
  except qa_error.Error:
567
    AssertCommand(["rm", tmp_hosts])
568

    
569

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

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

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

    
580
  master_node = qa_config.GetMasterNode()
581

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

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

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

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

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

    
608

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

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

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

    
625

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

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

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

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

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

    
646
      return result
647
    return wrapper
648
  return decorator