Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 05325a35

History | View | Annotate | Download (17 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 AssertNotEqual(first, second):
122
  """Raises an error when values are equal.
123

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

    
128

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

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

    
136

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

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

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

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

    
156
  return result
157

    
158

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

162
  @param cmd: either a string (the command to execute) or a list (to
163
      be converted using L{utils.ShellQuoteArgs} into a string)
164
  @type fail: boolean
165
  @param fail: if the command is expected to fail instead of succeeding
166
  @param node: if passed, it should be the node on which the command
167
      should be executed, instead of the master node (can be either a
168
      dict or a string)
169
  @return: the return code of the command
170
  @raise qa_error.Error: if the command fails when it shouldn't or vice versa
171

172
  """
173
  if node is None:
174
    node = qa_config.GetMasterNode()
175

    
176
  nodename = _GetName(node, "primary")
177

    
178
  if isinstance(cmd, basestring):
179
    cmdstr = cmd
180
  else:
181
    cmdstr = utils.ShellQuoteArgs(cmd)
182

    
183
  rcode = StartSSH(nodename, cmdstr).wait()
184

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

    
194
  return rcode
195

    
196

    
197
def GetSSHCommand(node, cmd, strict=True, opts=None, tty=None):
198
  """Builds SSH command to be executed.
199

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

212
  """
213
  args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
214

    
215
  if tty is None:
216
    tty = sys.stdout.isatty()
217

    
218
  if tty:
219
    args.append("-t")
220

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

    
238
  return args
239

    
240

    
241
def StartLocalCommand(cmd, _nolog_opts=False, **kwargs):
242
  """Starts a local command.
243

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

    
252

    
253
def StartSSH(node, cmd, strict=True):
254
  """Starts SSH.
255

256
  """
257
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
258
                           _nolog_opts=True)
259

    
260

    
261
def StartMultiplexer(node):
262
  """Starts a multiplexer command.
263

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

266
  """
267
  if node in _MULTIPLEXERS:
268
    return
269

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

    
278

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

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

    
288

    
289
def GetCommandOutput(node, cmd, tty=None):
290
  """Returns the output of a command executed on the given node.
291

292
  """
293
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
294
                        stdout=subprocess.PIPE)
295
  AssertEqual(p.wait(), 0)
296
  return p.stdout.read()
297

    
298

    
299
def UploadFile(node, src):
300
  """Uploads a file to a node and returns the filename.
301

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

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

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

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

    
320
    # Return temporary filename
321
    return p.stdout.read().strip()
322
  finally:
323
    f.close()
324

    
325

    
326
def UploadData(node, data, mode=0600, filename=None):
327
  """Uploads data to a node and returns the filename.
328

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

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

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

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

    
351

    
352
def BackupFile(node, path):
353
  """Creates a backup of a file on the node and returns the filename.
354

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

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

    
364
  # Return temporary filename
365
  return GetCommandOutput(node, cmd).strip()
366

    
367

    
368
def _ResolveName(cmd, key):
369
  """Helper function.
370

371
  """
372
  master = qa_config.GetMasterNode()
373

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

    
381

    
382
def ResolveInstanceName(instance):
383
  """Gets the full name of an instance.
384

385
  @type instance: string
386
  @param instance: Instance name
387

388
  """
389
  return _ResolveName(["gnt-instance", "info", instance],
390
                      "Instance name")
391

    
392

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

396
  """
397
  return _ResolveName(["gnt-node", "info", node["primary"]],
398
                      "Node name")
399

    
400

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

404
  """
405
  master = qa_config.GetMasterNode()
406
  node_name = ResolveNodeName(node)
407

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

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

    
420
  return instances
421

    
422

    
423
def _SelectQueryFields(rnd, fields):
424
  """Generates a list of fields for query tests.
425

426
  """
427
  # Create copy for shuffling
428
  fields = list(fields)
429
  rnd.shuffle(fields)
430

    
431
  # Check all fields
432
  yield fields
433
  yield sorted(fields)
434

    
435
  # Duplicate fields
436
  yield fields + fields
437

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

    
442

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

446
  """
447
  master = qa_config.GetMasterNode()
448

    
449
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
450
         "--output", ",".join(fields)]
451

    
452
  if names:
453
    cmd.extend(names)
454

    
455
  return GetCommandOutput(master["primary"],
456
                          utils.ShellQuoteArgs(cmd)).splitlines()
457

    
458

    
459
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
460
  """Runs a number of tests on query commands.
461

462
  @param cmd: Command name
463
  @param fields: List of field names
464

465
  """
466
  rnd = random.Random(hash(cmd))
467

    
468
  fields = list(fields)
469
  rnd.shuffle(fields)
470

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

    
475
  if namefield is not None:
476
    namelist_fn = compat.partial(_List, cmd, [namefield])
477

    
478
    # When no names were requested, the list must be sorted
479
    names = namelist_fn(None)
480
    AssertEqual(names, utils.NiceSort(names))
481

    
482
    # When requesting specific names, the order must be kept
483
    revnames = list(reversed(names))
484
    AssertEqual(namelist_fn(revnames), revnames)
485

    
486
    randnames = list(names)
487
    rnd.shuffle(randnames)
488
    AssertEqual(namelist_fn(randnames), randnames)
489

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

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

    
500

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

    
504
  # Listing fields
505
  AssertCommand([cmd, "list-fields"])
506
  AssertCommand([cmd, "list-fields"] + fields)
507

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

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

    
520

    
521
def _FormatWithColor(text, seq):
522
  if not seq:
523
    return text
524
  return "%s%s%s" % (seq, text, _RESET_SEQ)
525

    
526

    
527
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
528
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
529
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
530

    
531

    
532
def AddToEtcHosts(hostnames):
533
  """Adds hostnames to /etc/hosts.
534

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

537
  """
538
  master = qa_config.GetMasterNode()
539
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
540

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

    
545
  try:
546
    AssertCommand("{ cat %s && echo -e '%s'; } > %s && mv %s %s" %
547
                  (utils.ShellQuote(pathutils.ETC_HOSTS),
548
                   "\\n".join(data),
549
                   utils.ShellQuote(tmp_hosts),
550
                   utils.ShellQuote(tmp_hosts),
551
                   utils.ShellQuote(pathutils.ETC_HOSTS)))
552
  except Exception:
553
    AssertCommand(["rm", "-f", tmp_hosts])
554
    raise
555

    
556

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

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

562
  """
563
  master = qa_config.GetMasterNode()
564
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
565
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
566

    
567
  sed_data = " ".join(hostnames)
568
  try:
569
    AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' %s > %s"
570
                   " && mv %s %s") %
571
                   (sed_data, utils.ShellQuote(pathutils.ETC_HOSTS),
572
                    quoted_tmp_hosts, quoted_tmp_hosts,
573
                    utils.ShellQuote(pathutils.ETC_HOSTS)))
574
  except Exception:
575
    AssertCommand(["rm", "-f", tmp_hosts])
576
    raise
577

    
578

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

582
  """
583
  instance_name = _GetName(instance, "name")
584

    
585
  script = qa_config.GetInstanceCheckScript()
586
  if not script:
587
    return
588

    
589
  master_node = qa_config.GetMasterNode()
590

    
591
  # Build command to connect to master node
592
  master_ssh = GetSSHCommand(master_node["primary"], "--")
593

    
594
  if running:
595
    running_shellval = "1"
596
    running_text = ""
597
  else:
598
    running_shellval = ""
599
    running_text = "not "
600

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

    
604
  args = [script, instance_name]
605
  env = {
606
    "PATH": constants.HOOKS_PATH,
607
    "RUN_UUID": _RUN_UUID,
608
    "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
609
    "INSTANCE_NAME": instance_name,
610
    "INSTANCE_RUNNING": running_shellval,
611
    }
612

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

    
617

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

621
  """
622
  if instarg == FIRST_ARG:
623
    instance = args[0]
624
  elif instarg == RETURN_VALUE:
625
    instance = result
626
  else:
627
    raise Exception("Invalid value '%s' for instance argument" % instarg)
628

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

    
634

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

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

645
  """
646
  def decorator(fn):
647
    @functools.wraps(fn)
648
    def wrapper(*args, **kwargs):
649
      _InstanceCheckInner(before, instarg, args, NotImplemented)
650

    
651
      result = fn(*args, **kwargs)
652

    
653
      _InstanceCheckInner(after, instarg, args, result)
654

    
655
      return result
656
    return wrapper
657
  return decorator