Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ f14a8b15

History | View | Annotate | Download (16.6 kB)

1
#
2
#
3

    
4
# Copyright (C) 2007, 2011 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", "-l", "root"]
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, **kwargs):
239
  """Starts a local command.
240

241
  """
242
  print "Command: %s" % utils.ShellQuoteArgs(cmd)
243
  return subprocess.Popen(cmd, shell=False, **kwargs)
244

    
245

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

249
  """
250
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict))
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
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
534
  data = []
535
  for localhost in ("::1", "127.0.0.1"):
536
    data.append("%s %s" % (localhost, " ".join(hostnames)))
537

    
538
  try:
539
    AssertCommand(("cat /etc/hosts > %s && echo -e '%s' >> %s && mv %s"
540
                   " /etc/hosts") % (quoted_tmp_hosts, "\\n".join(data),
541
                                     quoted_tmp_hosts, quoted_tmp_hosts))
542
  except qa_error.Error:
543
    AssertCommand(["rm", tmp_hosts])
544

    
545

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

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

551
  """
552
  master = qa_config.GetMasterNode()
553
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
554
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
555

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

    
564

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

568
  """
569
  instance_name = _GetName(instance, "name")
570

    
571
  script = qa_config.GetInstanceCheckScript()
572
  if not script:
573
    return
574

    
575
  master_node = qa_config.GetMasterNode()
576

    
577
  # Build command to connect to master node
578
  master_ssh = GetSSHCommand(master_node["primary"], "--")
579

    
580
  if running:
581
    running_shellval = "1"
582
    running_text = ""
583
  else:
584
    running_shellval = ""
585
    running_text = "not "
586

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

    
590
  args = [script, instance_name]
591
  env = {
592
    "PATH": constants.HOOKS_PATH,
593
    "RUN_UUID": _RUN_UUID,
594
    "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
595
    "INSTANCE_NAME": instance_name,
596
    "INSTANCE_RUNNING": running_shellval,
597
    }
598

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

    
603

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

607
  """
608
  if instarg == FIRST_ARG:
609
    instance = args[0]
610
  elif instarg == RETURN_VALUE:
611
    instance = result
612
  else:
613
    raise Exception("Invalid value '%s' for instance argument" % instarg)
614

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

    
620

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

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

631
  """
632
  def decorator(fn):
633
    @functools.wraps(fn)
634
    def wrapper(*args, **kwargs):
635
      _InstanceCheckInner(before, instarg, args, NotImplemented)
636

    
637
      result = fn(*args, **kwargs)
638

    
639
      _InstanceCheckInner(after, instarg, args, result)
640

    
641
      return result
642
    return wrapper
643
  return decorator