Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 2ac35588

History | View | Annotate | Download (16.2 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 AssertCommand(cmd, fail=False, node=None):
137
  """Checks that a remote command succeeds.
138

139
  @param cmd: either a string (the command to execute) or a list (to
140
      be converted using L{utils.ShellQuoteArgs} into a string)
141
  @type fail: boolean
142
  @param fail: if the command is expected to fail instead of succeeding
143
  @param node: if passed, it should be the node on which the command
144
      should be executed, instead of the master node (can be either a
145
      dict or a string)
146

147
  """
148
  if node is None:
149
    node = qa_config.GetMasterNode()
150

    
151
  if isinstance(node, basestring):
152
    nodename = node
153
  else:
154
    nodename = node["primary"]
155

    
156
  if isinstance(cmd, basestring):
157
    cmdstr = cmd
158
  else:
159
    cmdstr = utils.ShellQuoteArgs(cmd)
160

    
161
  rcode = StartSSH(nodename, cmdstr).wait()
162

    
163
  if fail:
164
    if rcode == 0:
165
      raise qa_error.Error("Command '%s' on node %s was expected to fail but"
166
                           " didn't" % (cmdstr, nodename))
167
  else:
168
    if rcode != 0:
169
      raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
170
                           (cmdstr, nodename, rcode))
171

    
172
  return rcode
173

    
174

    
175
def GetSSHCommand(node, cmd, strict=True, opts=None, tty=True):
176
  """Builds SSH command to be executed.
177

178
  @type node: string
179
  @param node: node the command should run on
180
  @type cmd: string
181
  @param cmd: command to be executed in the node; if None or empty
182
      string, no command will be executed
183
  @type strict: boolean
184
  @param strict: whether to enable strict host key checking
185
  @type opts: list
186
  @param opts: list of additional options
187
  @type tty: Bool
188
  @param tty: If we should use tty
189

190
  """
191
  args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-l", "root"]
192

    
193
  if tty:
194
    args.append("-t")
195

    
196
  if strict:
197
    tmp = "yes"
198
  else:
199
    tmp = "no"
200
  args.append("-oStrictHostKeyChecking=%s" % tmp)
201
  args.append("-oClearAllForwardings=yes")
202
  args.append("-oForwardAgent=yes")
203
  if opts:
204
    args.extend(opts)
205
  if node in _MULTIPLEXERS:
206
    spath = _MULTIPLEXERS[node][0]
207
    args.append("-oControlPath=%s" % spath)
208
    args.append("-oControlMaster=no")
209
  args.append(node)
210
  if cmd:
211
    args.append(cmd)
212

    
213
  return args
214

    
215

    
216
def StartLocalCommand(cmd, **kwargs):
217
  """Starts a local command.
218

219
  """
220
  print "Command: %s" % utils.ShellQuoteArgs(cmd)
221
  return subprocess.Popen(cmd, shell=False, **kwargs)
222

    
223

    
224
def StartSSH(node, cmd, strict=True):
225
  """Starts SSH.
226

227
  """
228
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict))
229

    
230

    
231
def StartMultiplexer(node):
232
  """Starts a multiplexer command.
233

234
  @param node: the node for which to open the multiplexer
235

236
  """
237
  if node in _MULTIPLEXERS:
238
    return
239

    
240
  # Note: yes, we only need mktemp, since we'll remove the file anyway
241
  sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
242
  utils.RemoveFile(sname)
243
  opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
244
  print "Created socket at %s" % sname
245
  child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
246
  _MULTIPLEXERS[node] = (sname, child)
247

    
248

    
249
def CloseMultiplexers():
250
  """Closes all current multiplexers and cleans up.
251

252
  """
253
  for node in _MULTIPLEXERS.keys():
254
    (sname, child) = _MULTIPLEXERS.pop(node)
255
    utils.KillProcess(child.pid, timeout=10, waitpid=True)
256
    utils.RemoveFile(sname)
257

    
258

    
259
def GetCommandOutput(node, cmd, tty=True):
260
  """Returns the output of a command executed on the given node.
261

262
  """
263
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
264
                        stdout=subprocess.PIPE)
265
  AssertEqual(p.wait(), 0)
266
  return p.stdout.read()
267

    
268

    
269
def UploadFile(node, src):
270
  """Uploads a file to a node and returns the filename.
271

272
  Caller needs to remove the returned file on the node when it's not needed
273
  anymore.
274

275
  """
276
  # Make sure nobody else has access to it while preserving local permissions
277
  mode = os.stat(src).st_mode & 0700
278

    
279
  cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
280
         '[[ -f "${tmp}" ]] && '
281
         'cat > "${tmp}" && '
282
         'echo "${tmp}"') % mode
283

    
284
  f = open(src, "r")
285
  try:
286
    p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
287
                         stdout=subprocess.PIPE)
288
    AssertEqual(p.wait(), 0)
289

    
290
    # Return temporary filename
291
    return p.stdout.read().strip()
292
  finally:
293
    f.close()
294

    
295

    
296
def UploadData(node, data, mode=0600, filename=None):
297
  """Uploads data 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
  if filename:
304
    tmp = "tmp=%s" % utils.ShellQuote(filename)
305
  else:
306
    tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
307
  cmd = ("%s && "
308
         "[[ -f \"${tmp}\" ]] && "
309
         "cat > \"${tmp}\" && "
310
         "echo \"${tmp}\"") % tmp
311

    
312
  p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
313
                       stdin=subprocess.PIPE, stdout=subprocess.PIPE)
314
  p.stdin.write(data)
315
  p.stdin.close()
316
  AssertEqual(p.wait(), 0)
317

    
318
  # Return temporary filename
319
  return p.stdout.read().strip()
320

    
321

    
322
def BackupFile(node, path):
323
  """Creates a backup of a file on the node and returns the filename.
324

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

328
  """
329
  cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
330
         "[[ -f \"$tmp\" ]] && "
331
         "cp %s $tmp && "
332
         "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
333

    
334
  # Return temporary filename
335
  return GetCommandOutput(node, cmd).strip()
336

    
337

    
338
def _ResolveName(cmd, key):
339
  """Helper function.
340

341
  """
342
  master = qa_config.GetMasterNode()
343

    
344
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
345
  for line in output.splitlines():
346
    (lkey, lvalue) = line.split(":", 1)
347
    if lkey == key:
348
      return lvalue.lstrip()
349
  raise KeyError("Key not found")
350

    
351

    
352
def ResolveInstanceName(instance):
353
  """Gets the full name of an instance.
354

355
  @type instance: string
356
  @param instance: Instance name
357

358
  """
359
  return _ResolveName(["gnt-instance", "info", instance],
360
                      "Instance name")
361

    
362

    
363
def ResolveNodeName(node):
364
  """Gets the full name of a node.
365

366
  """
367
  return _ResolveName(["gnt-node", "info", node["primary"]],
368
                      "Node name")
369

    
370

    
371
def GetNodeInstances(node, secondaries=False):
372
  """Gets a list of instances on a node.
373

374
  """
375
  master = qa_config.GetMasterNode()
376
  node_name = ResolveNodeName(node)
377

    
378
  # Get list of all instances
379
  cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
380
         "--output=name,pnode,snodes"]
381
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
382

    
383
  instances = []
384
  for line in output.splitlines():
385
    (name, pnode, snodes) = line.split(":", 2)
386
    if ((not secondaries and pnode == node_name) or
387
        (secondaries and node_name in snodes.split(","))):
388
      instances.append(name)
389

    
390
  return instances
391

    
392

    
393
def _SelectQueryFields(rnd, fields):
394
  """Generates a list of fields for query tests.
395

396
  """
397
  # Create copy for shuffling
398
  fields = list(fields)
399
  rnd.shuffle(fields)
400

    
401
  # Check all fields
402
  yield fields
403
  yield sorted(fields)
404

    
405
  # Duplicate fields
406
  yield fields + fields
407

    
408
  # Check small groups of fields
409
  while fields:
410
    yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
411

    
412

    
413
def _List(listcmd, fields, names):
414
  """Runs a list command.
415

416
  """
417
  master = qa_config.GetMasterNode()
418

    
419
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
420
         "--output", ",".join(fields)]
421

    
422
  if names:
423
    cmd.extend(names)
424

    
425
  return GetCommandOutput(master["primary"],
426
                          utils.ShellQuoteArgs(cmd)).splitlines()
427

    
428

    
429
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
430
  """Runs a number of tests on query commands.
431

432
  @param cmd: Command name
433
  @param fields: List of field names
434

435
  """
436
  rnd = random.Random(hash(cmd))
437

    
438
  fields = list(fields)
439
  rnd.shuffle(fields)
440

    
441
  # Test a number of field combinations
442
  for testfields in _SelectQueryFields(rnd, fields):
443
    AssertCommand([cmd, "list", "--output", ",".join(testfields)])
444

    
445
  if namefield is not None:
446
    namelist_fn = compat.partial(_List, cmd, [namefield])
447

    
448
    # When no names were requested, the list must be sorted
449
    names = namelist_fn(None)
450
    AssertEqual(names, utils.NiceSort(names))
451

    
452
    # When requesting specific names, the order must be kept
453
    revnames = list(reversed(names))
454
    AssertEqual(namelist_fn(revnames), revnames)
455

    
456
    randnames = list(names)
457
    rnd.shuffle(randnames)
458
    AssertEqual(namelist_fn(randnames), randnames)
459

    
460
  if test_unknown:
461
    # Listing unknown items must fail
462
    AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
463
                  fail=True)
464

    
465
  # Check exit code for listing unknown field
466
  AssertEqual(AssertCommand([cmd, "list", "--output=field/does/not/exist"],
467
                            fail=True),
468
              constants.EXIT_UNKNOWN_FIELD)
469

    
470

    
471
def GenericQueryFieldsTest(cmd, fields):
472
  master = qa_config.GetMasterNode()
473

    
474
  # Listing fields
475
  AssertCommand([cmd, "list-fields"])
476
  AssertCommand([cmd, "list-fields"] + fields)
477

    
478
  # Check listed fields (all, must be sorted)
479
  realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
480
  output = GetCommandOutput(master["primary"],
481
                            utils.ShellQuoteArgs(realcmd)).splitlines()
482
  AssertEqual([line.split("|", 1)[0] for line in output],
483
              utils.NiceSort(fields))
484

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

    
490

    
491
def _FormatWithColor(text, seq):
492
  if not seq:
493
    return text
494
  return "%s%s%s" % (seq, text, _RESET_SEQ)
495

    
496

    
497
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
498
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
499
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
500

    
501

    
502
def AddToEtcHosts(hostnames):
503
  """Adds hostnames to /etc/hosts.
504

505
  @param hostnames: List of hostnames first used A records, all other CNAMEs
506

507
  """
508
  master = qa_config.GetMasterNode()
509
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
510

    
511
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
512
  data = []
513
  for localhost in ("::1", "127.0.0.1"):
514
    data.append("%s %s" % (localhost, " ".join(hostnames)))
515

    
516
  try:
517
    AssertCommand(("cat /etc/hosts > %s && echo -e '%s' >> %s && mv %s"
518
                   " /etc/hosts") % (quoted_tmp_hosts, "\\n".join(data),
519
                                     quoted_tmp_hosts, quoted_tmp_hosts))
520
  except qa_error.Error:
521
    AssertCommand(["rm", tmp_hosts])
522

    
523

    
524
def RemoveFromEtcHosts(hostnames):
525
  """Remove hostnames from /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
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
533

    
534
  sed_data = " ".join(hostnames)
535
  try:
536
    AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' /etc/hosts > %s"
537
                   " && mv %s /etc/hosts") % (sed_data, quoted_tmp_hosts,
538
                                              quoted_tmp_hosts))
539
  except qa_error.Error:
540
    AssertCommand(["rm", tmp_hosts])
541

    
542

    
543
def RunInstanceCheck(instance, running):
544
  """Check if instance is running or not.
545

546
  """
547
  if isinstance(instance, basestring):
548
    instance_name = instance
549
  else:
550
    instance_name = instance["name"]
551

    
552
  if not ht.TNonEmptyString(instance_name):
553
    raise Exception("Invalid instance name '%s'" % instance_name)
554

    
555
  script = qa_config.GetInstanceCheckScript()
556
  if not script:
557
    return
558

    
559
  master_node = qa_config.GetMasterNode()
560

    
561
  # Build command to connect to master node
562
  master_ssh = GetSSHCommand(master_node["primary"], "--")
563

    
564
  if running:
565
    running_shellval = "1"
566
    running_text = ""
567
  else:
568
    running_shellval = ""
569
    running_text = "not "
570

    
571
  print FormatInfo("Checking if instance '%s' is %srunning" %
572
                   (instance_name, running_text))
573

    
574
  args = [script, instance_name]
575
  env = {
576
    "PATH": constants.HOOKS_PATH,
577
    "RUN_UUID": _RUN_UUID,
578
    "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
579
    "INSTANCE_NAME": instance_name,
580
    "INSTANCE_RUNNING": running_shellval,
581
    }
582

    
583
  result = os.spawnve(os.P_WAIT, script, args, env)
584
  if result != 0:
585
    raise qa_error.Error("Instance check failed with result %s" % result)
586

    
587

    
588
def _InstanceCheckInner(expected, instarg, args, result):
589
  """Helper function used by L{InstanceCheck}.
590

591
  """
592
  if instarg == FIRST_ARG:
593
    instance = args[0]
594
  elif instarg == RETURN_VALUE:
595
    instance = result
596
  else:
597
    raise Exception("Invalid value '%s' for instance argument" % instarg)
598

    
599
  if expected in (INST_DOWN, INST_UP):
600
    RunInstanceCheck(instance, (expected == INST_UP))
601
  elif expected is not None:
602
    raise Exception("Invalid value '%s'" % expected)
603

    
604

    
605
def InstanceCheck(before, after, instarg):
606
  """Decorator to check instance status before and after test.
607

608
  @param before: L{INST_DOWN} if instance must be stopped before test,
609
    L{INST_UP} if instance must be running before test, L{None} to not check.
610
  @param after: L{INST_DOWN} if instance must be stopped after test,
611
    L{INST_UP} if instance must be running after test, L{None} to not check.
612
  @param instarg: L{FIRST_ARG} to use first argument to test as instance (a
613
    dictionary), L{RETURN_VALUE} to use return value (disallows pre-checks)
614

615
  """
616
  def decorator(fn):
617
    @functools.wraps(fn)
618
    def wrapper(*args, **kwargs):
619
      _InstanceCheckInner(before, instarg, args, NotImplemented)
620

    
621
      result = fn(*args, **kwargs)
622

    
623
      _InstanceCheckInner(after, instarg, args, result)
624

    
625
      return result
626
    return wrapper
627
  return decorator