Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 22381768

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

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

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

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

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

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

    
192
  return rcode
193

    
194

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

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

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

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

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

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

    
236
  return args
237

    
238

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

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

    
250

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

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

    
258

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

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

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

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

    
276

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

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

    
286

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

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

    
296

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

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

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

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

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

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

    
323

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

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

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

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

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

    
349

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

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

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

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

    
365

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

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

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

    
379

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

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

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

    
390

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

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

    
398

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

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

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

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

    
418
  return instances
419

    
420

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

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

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

    
433
  # Duplicate fields
434
  yield fields + fields
435

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

    
440

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

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

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

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

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

    
456

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

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

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

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

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

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

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

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

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

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

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

    
498

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

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

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

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

    
518

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

    
524

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

    
529

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

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

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

    
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 %s && echo -e '%s'; } > %s && mv %s %s" %
545
                  (utils.ShellQuote(pathutils.ETC_HOSTS),
546
                   "\\n".join(data),
547
                   utils.ShellQuote(tmp_hosts),
548
                   utils.ShellQuote(tmp_hosts),
549
                   utils.ShellQuote(pathutils.ETC_HOSTS)))
550
  except Exception:
551
    AssertCommand(["rm", "-f", tmp_hosts])
552
    raise
553

    
554

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

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

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

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

    
576

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

580
  """
581
  instance_name = _GetName(instance, "name")
582

    
583
  script = qa_config.GetInstanceCheckScript()
584
  if not script:
585
    return
586

    
587
  master_node = qa_config.GetMasterNode()
588

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

    
592
  if running:
593
    running_shellval = "1"
594
    running_text = ""
595
  else:
596
    running_shellval = ""
597
    running_text = "not "
598

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

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

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

    
615

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

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

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

    
632

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

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

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

    
649
      result = fn(*args, **kwargs)
650

    
651
      _InstanceCheckInner(after, instarg, args, result)
652

    
653
      return result
654
    return wrapper
655
  return decorator