Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 889bed16

History | View | Annotate | Download (16.5 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=True):
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: Bool
207
  @param tty: If we should use tty
208

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

    
212
  if tty:
213
    args.append("-t")
214

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

    
232
  return args
233

    
234

    
235
def StartLocalCommand(cmd, **kwargs):
236
  """Starts a local command.
237

238
  """
239
  print "Command: %s" % utils.ShellQuoteArgs(cmd)
240
  return subprocess.Popen(cmd, shell=False, **kwargs)
241

    
242

    
243
def StartSSH(node, cmd, strict=True):
244
  """Starts SSH.
245

246
  """
247
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict))
248

    
249

    
250
def StartMultiplexer(node):
251
  """Starts a multiplexer command.
252

253
  @param node: the node for which to open the multiplexer
254

255
  """
256
  if node in _MULTIPLEXERS:
257
    return
258

    
259
  # Note: yes, we only need mktemp, since we'll remove the file anyway
260
  sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
261
  utils.RemoveFile(sname)
262
  opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
263
  print "Created socket at %s" % sname
264
  child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
265
  _MULTIPLEXERS[node] = (sname, child)
266

    
267

    
268
def CloseMultiplexers():
269
  """Closes all current multiplexers and cleans up.
270

271
  """
272
  for node in _MULTIPLEXERS.keys():
273
    (sname, child) = _MULTIPLEXERS.pop(node)
274
    utils.KillProcess(child.pid, timeout=10, waitpid=True)
275
    utils.RemoveFile(sname)
276

    
277

    
278
def GetCommandOutput(node, cmd, tty=True):
279
  """Returns the output of a command executed on the given node.
280

281
  """
282
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
283
                        stdout=subprocess.PIPE)
284
  AssertEqual(p.wait(), 0)
285
  return p.stdout.read()
286

    
287

    
288
def UploadFile(node, src):
289
  """Uploads a file to a node and returns the filename.
290

291
  Caller needs to remove the returned file on the node when it's not needed
292
  anymore.
293

294
  """
295
  # Make sure nobody else has access to it while preserving local permissions
296
  mode = os.stat(src).st_mode & 0700
297

    
298
  cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
299
         '[[ -f "${tmp}" ]] && '
300
         'cat > "${tmp}" && '
301
         'echo "${tmp}"') % mode
302

    
303
  f = open(src, "r")
304
  try:
305
    p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
306
                         stdout=subprocess.PIPE)
307
    AssertEqual(p.wait(), 0)
308

    
309
    # Return temporary filename
310
    return p.stdout.read().strip()
311
  finally:
312
    f.close()
313

    
314

    
315
def UploadData(node, data, mode=0600, filename=None):
316
  """Uploads data to a node and returns the filename.
317

318
  Caller needs to remove the returned file on the node when it's not needed
319
  anymore.
320

321
  """
322
  if filename:
323
    tmp = "tmp=%s" % utils.ShellQuote(filename)
324
  else:
325
    tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
326
  cmd = ("%s && "
327
         "[[ -f \"${tmp}\" ]] && "
328
         "cat > \"${tmp}\" && "
329
         "echo \"${tmp}\"") % tmp
330

    
331
  p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
332
                       stdin=subprocess.PIPE, stdout=subprocess.PIPE)
333
  p.stdin.write(data)
334
  p.stdin.close()
335
  AssertEqual(p.wait(), 0)
336

    
337
  # Return temporary filename
338
  return p.stdout.read().strip()
339

    
340

    
341
def BackupFile(node, path):
342
  """Creates a backup of a file on the node and returns the filename.
343

344
  Caller needs to remove the returned file on the node when it's not needed
345
  anymore.
346

347
  """
348
  cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
349
         "[[ -f \"$tmp\" ]] && "
350
         "cp %s $tmp && "
351
         "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
352

    
353
  # Return temporary filename
354
  return GetCommandOutput(node, cmd).strip()
355

    
356

    
357
def _ResolveName(cmd, key):
358
  """Helper function.
359

360
  """
361
  master = qa_config.GetMasterNode()
362

    
363
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
364
  for line in output.splitlines():
365
    (lkey, lvalue) = line.split(":", 1)
366
    if lkey == key:
367
      return lvalue.lstrip()
368
  raise KeyError("Key not found")
369

    
370

    
371
def ResolveInstanceName(instance):
372
  """Gets the full name of an instance.
373

374
  @type instance: string
375
  @param instance: Instance name
376

377
  """
378
  return _ResolveName(["gnt-instance", "info", instance],
379
                      "Instance name")
380

    
381

    
382
def ResolveNodeName(node):
383
  """Gets the full name of a node.
384

385
  """
386
  return _ResolveName(["gnt-node", "info", node["primary"]],
387
                      "Node name")
388

    
389

    
390
def GetNodeInstances(node, secondaries=False):
391
  """Gets a list of instances on a node.
392

393
  """
394
  master = qa_config.GetMasterNode()
395
  node_name = ResolveNodeName(node)
396

    
397
  # Get list of all instances
398
  cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
399
         "--output=name,pnode,snodes"]
400
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
401

    
402
  instances = []
403
  for line in output.splitlines():
404
    (name, pnode, snodes) = line.split(":", 2)
405
    if ((not secondaries and pnode == node_name) or
406
        (secondaries and node_name in snodes.split(","))):
407
      instances.append(name)
408

    
409
  return instances
410

    
411

    
412
def _SelectQueryFields(rnd, fields):
413
  """Generates a list of fields for query tests.
414

415
  """
416
  # Create copy for shuffling
417
  fields = list(fields)
418
  rnd.shuffle(fields)
419

    
420
  # Check all fields
421
  yield fields
422
  yield sorted(fields)
423

    
424
  # Duplicate fields
425
  yield fields + fields
426

    
427
  # Check small groups of fields
428
  while fields:
429
    yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
430

    
431

    
432
def _List(listcmd, fields, names):
433
  """Runs a list command.
434

435
  """
436
  master = qa_config.GetMasterNode()
437

    
438
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
439
         "--output", ",".join(fields)]
440

    
441
  if names:
442
    cmd.extend(names)
443

    
444
  return GetCommandOutput(master["primary"],
445
                          utils.ShellQuoteArgs(cmd)).splitlines()
446

    
447

    
448
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
449
  """Runs a number of tests on query commands.
450

451
  @param cmd: Command name
452
  @param fields: List of field names
453

454
  """
455
  rnd = random.Random(hash(cmd))
456

    
457
  fields = list(fields)
458
  rnd.shuffle(fields)
459

    
460
  # Test a number of field combinations
461
  for testfields in _SelectQueryFields(rnd, fields):
462
    AssertCommand([cmd, "list", "--output", ",".join(testfields)])
463

    
464
  if namefield is not None:
465
    namelist_fn = compat.partial(_List, cmd, [namefield])
466

    
467
    # When no names were requested, the list must be sorted
468
    names = namelist_fn(None)
469
    AssertEqual(names, utils.NiceSort(names))
470

    
471
    # When requesting specific names, the order must be kept
472
    revnames = list(reversed(names))
473
    AssertEqual(namelist_fn(revnames), revnames)
474

    
475
    randnames = list(names)
476
    rnd.shuffle(randnames)
477
    AssertEqual(namelist_fn(randnames), randnames)
478

    
479
  if test_unknown:
480
    # Listing unknown items must fail
481
    AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
482
                  fail=True)
483

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

    
489

    
490
def GenericQueryFieldsTest(cmd, fields):
491
  master = qa_config.GetMasterNode()
492

    
493
  # Listing fields
494
  AssertCommand([cmd, "list-fields"])
495
  AssertCommand([cmd, "list-fields"] + fields)
496

    
497
  # Check listed fields (all, must be sorted)
498
  realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
499
  output = GetCommandOutput(master["primary"],
500
                            utils.ShellQuoteArgs(realcmd)).splitlines()
501
  AssertEqual([line.split("|", 1)[0] for line in output],
502
              utils.NiceSort(fields))
503

    
504
  # Check exit code for listing unknown field
505
  AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
506
                            fail=True),
507
              constants.EXIT_UNKNOWN_FIELD)
508

    
509

    
510
def _FormatWithColor(text, seq):
511
  if not seq:
512
    return text
513
  return "%s%s%s" % (seq, text, _RESET_SEQ)
514

    
515

    
516
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
517
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
518
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
519

    
520

    
521
def AddToEtcHosts(hostnames):
522
  """Adds hostnames to /etc/hosts.
523

524
  @param hostnames: List of hostnames first used A records, all other CNAMEs
525

526
  """
527
  master = qa_config.GetMasterNode()
528
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
529

    
530
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
531
  data = []
532
  for localhost in ("::1", "127.0.0.1"):
533
    data.append("%s %s" % (localhost, " ".join(hostnames)))
534

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

    
542

    
543
def RemoveFromEtcHosts(hostnames):
544
  """Remove hostnames from /etc/hosts.
545

546
  @param hostnames: List of hostnames first used A records, all other CNAMEs
547

548
  """
549
  master = qa_config.GetMasterNode()
550
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
551
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
552

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

    
561

    
562
def RunInstanceCheck(instance, running):
563
  """Check if instance is running or not.
564

565
  """
566
  instance_name = _GetName(instance, "name")
567

    
568
  script = qa_config.GetInstanceCheckScript()
569
  if not script:
570
    return
571

    
572
  master_node = qa_config.GetMasterNode()
573

    
574
  # Build command to connect to master node
575
  master_ssh = GetSSHCommand(master_node["primary"], "--")
576

    
577
  if running:
578
    running_shellval = "1"
579
    running_text = ""
580
  else:
581
    running_shellval = ""
582
    running_text = "not "
583

    
584
  print FormatInfo("Checking if instance '%s' is %srunning" %
585
                   (instance_name, running_text))
586

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

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

    
600

    
601
def _InstanceCheckInner(expected, instarg, args, result):
602
  """Helper function used by L{InstanceCheck}.
603

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

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

    
617

    
618
def InstanceCheck(before, after, instarg):
619
  """Decorator to check instance status before and after test.
620

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

628
  """
629
  def decorator(fn):
630
    @functools.wraps(fn)
631
    def wrapper(*args, **kwargs):
632
      _InstanceCheckInner(before, instarg, args, NotImplemented)
633

    
634
      result = fn(*args, **kwargs)
635

    
636
      _InstanceCheckInner(after, instarg, args, result)
637

    
638
      return result
639
    return wrapper
640
  return decorator