Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 56b9f2db

History | View | Annotate | Download (18.1 kB)

1
#
2
#
3

    
4
# Copyright (C) 2007, 2011, 2012, 2013 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 AssertMatch(string, pattern):
122
  """Raises an error when string doesn't match regexp pattern.
123

124
  """
125
  if not re.match(pattern, string):
126
    raise qa_error.Error("%r doesn't match /%r/" % (string, pattern))
127

    
128

    
129
def _GetName(entity, key):
130
  """Tries to get name of an entity.
131

132
  @type entity: string or dict
133
  @type key: string
134
  @param key: Dictionary key containing name
135

136
  """
137
  if isinstance(entity, basestring):
138
    result = entity
139
  elif isinstance(entity, dict):
140
    result = entity[key]
141
  else:
142
    raise qa_error.Error("Expected string or dictionary, got %s: %s" %
143
                         (type(entity), entity))
144

    
145
  if not ht.TNonEmptyString(result):
146
    raise Exception("Invalid name '%s'" % result)
147

    
148
  return result
149

    
150

    
151
def _AssertRetCode(rcode, fail, cmdstr, nodename):
152
  """Check the return value from a command and possibly raise an exception.
153

154
  """
155
  if fail and rcode == 0:
156
    raise qa_error.Error("Command '%s' on node %s was expected to fail but"
157
                         " didn't" % (cmdstr, nodename))
158
  elif not fail and rcode != 0:
159
    raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
160
                         (cmdstr, nodename, rcode))
161

    
162

    
163
def AssertCommand(cmd, fail=False, node=None, log_cmd=True):
164
  """Checks that a remote command succeeds.
165

166
  @param cmd: either a string (the command to execute) or a list (to
167
      be converted using L{utils.ShellQuoteArgs} into a string)
168
  @type fail: boolean
169
  @param fail: if the command is expected to fail instead of succeeding
170
  @param node: if passed, it should be the node on which the command
171
      should be executed, instead of the master node (can be either a
172
      dict or a string)
173
  @param log_cmd: if False, the command won't be logged (simply passed to
174
      StartSSH)
175
  @return: the return code of the command
176
  @raise qa_error.Error: if the command fails when it shouldn't or vice versa
177

178
  """
179
  if node is None:
180
    node = qa_config.GetMasterNode()
181

    
182
  nodename = _GetName(node, "primary")
183

    
184
  if isinstance(cmd, basestring):
185
    cmdstr = cmd
186
  else:
187
    cmdstr = utils.ShellQuoteArgs(cmd)
188

    
189
  rcode = StartSSH(nodename, cmdstr, log_cmd=log_cmd).wait()
190
  _AssertRetCode(rcode, fail, cmdstr, nodename)
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, log_cmd=True, **kwargs):
240
  """Starts a local command.
241

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

    
251

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

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

    
259

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

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

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

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

    
277

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

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

    
287

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

291
  @type node: string
292
  @param node: node the command should run on
293
  @type cmd: string
294
  @param cmd: command to be executed in the node (cannot be empty or None)
295
  @type tty: bool or None
296
  @param tty: if we should use tty; if None, it will be auto-detected
297
  @type fail: bool
298
  @param fail: whether the command is expected to fail
299
  """
300
  assert cmd
301
  p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
302
                        stdout=subprocess.PIPE)
303
  rcode = p.wait()
304
  _AssertRetCode(rcode, fail, node, cmd)
305
  return p.stdout.read()
306

    
307

    
308
def UploadFile(node, src):
309
  """Uploads a file to a node and returns the filename.
310

311
  Caller needs to remove the returned file on the node when it's not needed
312
  anymore.
313

314
  """
315
  # Make sure nobody else has access to it while preserving local permissions
316
  mode = os.stat(src).st_mode & 0700
317

    
318
  cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
319
         '[[ -f "${tmp}" ]] && '
320
         'cat > "${tmp}" && '
321
         'echo "${tmp}"') % mode
322

    
323
  f = open(src, "r")
324
  try:
325
    p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
326
                         stdout=subprocess.PIPE)
327
    AssertEqual(p.wait(), 0)
328

    
329
    # Return temporary filename
330
    return p.stdout.read().strip()
331
  finally:
332
    f.close()
333

    
334

    
335
def UploadData(node, data, mode=0600, filename=None):
336
  """Uploads data to a node and returns the filename.
337

338
  Caller needs to remove the returned file on the node when it's not needed
339
  anymore.
340

341
  """
342
  if filename:
343
    tmp = "tmp=%s" % utils.ShellQuote(filename)
344
  else:
345
    tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
346
  cmd = ("%s && "
347
         "[[ -f \"${tmp}\" ]] && "
348
         "cat > \"${tmp}\" && "
349
         "echo \"${tmp}\"") % tmp
350

    
351
  p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
352
                       stdin=subprocess.PIPE, stdout=subprocess.PIPE)
353
  p.stdin.write(data)
354
  p.stdin.close()
355
  AssertEqual(p.wait(), 0)
356

    
357
  # Return temporary filename
358
  return p.stdout.read().strip()
359

    
360

    
361
def BackupFile(node, path):
362
  """Creates a backup of a file on the node and returns the filename.
363

364
  Caller needs to remove the returned file on the node when it's not needed
365
  anymore.
366

367
  """
368
  cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
369
         "[[ -f \"$tmp\" ]] && "
370
         "cp %s $tmp && "
371
         "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
372

    
373
  # Return temporary filename
374
  return GetCommandOutput(node, cmd).strip()
375

    
376

    
377
def _ResolveName(cmd, key):
378
  """Helper function.
379

380
  """
381
  master = qa_config.GetMasterNode()
382

    
383
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
384
  for line in output.splitlines():
385
    (lkey, lvalue) = line.split(":", 1)
386
    if lkey == key:
387
      return lvalue.lstrip()
388
  raise KeyError("Key not found")
389

    
390

    
391
def ResolveInstanceName(instance):
392
  """Gets the full name of an instance.
393

394
  @type instance: string
395
  @param instance: Instance name
396

397
  """
398
  return _ResolveName(["gnt-instance", "info", instance],
399
                      "Instance name")
400

    
401

    
402
def ResolveNodeName(node):
403
  """Gets the full name of a node.
404

405
  """
406
  return _ResolveName(["gnt-node", "info", node["primary"]],
407
                      "Node name")
408

    
409

    
410
def GetNodeInstances(node, secondaries=False):
411
  """Gets a list of instances on a node.
412

413
  """
414
  master = qa_config.GetMasterNode()
415
  node_name = ResolveNodeName(node)
416

    
417
  # Get list of all instances
418
  cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
419
         "--output=name,pnode,snodes"]
420
  output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
421

    
422
  instances = []
423
  for line in output.splitlines():
424
    (name, pnode, snodes) = line.split(":", 2)
425
    if ((not secondaries and pnode == node_name) or
426
        (secondaries and node_name in snodes.split(","))):
427
      instances.append(name)
428

    
429
  return instances
430

    
431

    
432
def _SelectQueryFields(rnd, fields):
433
  """Generates a list of fields for query tests.
434

435
  """
436
  # Create copy for shuffling
437
  fields = list(fields)
438
  rnd.shuffle(fields)
439

    
440
  # Check all fields
441
  yield fields
442
  yield sorted(fields)
443

    
444
  # Duplicate fields
445
  yield fields + fields
446

    
447
  # Check small groups of fields
448
  while fields:
449
    yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
450

    
451

    
452
def _List(listcmd, fields, names):
453
  """Runs a list command.
454

455
  """
456
  master = qa_config.GetMasterNode()
457

    
458
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
459
         "--output", ",".join(fields)]
460

    
461
  if names:
462
    cmd.extend(names)
463

    
464
  return GetCommandOutput(master["primary"],
465
                          utils.ShellQuoteArgs(cmd)).splitlines()
466

    
467

    
468
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
469
  """Runs a number of tests on query commands.
470

471
  @param cmd: Command name
472
  @param fields: List of field names
473

474
  """
475
  rnd = random.Random(hash(cmd))
476

    
477
  fields = list(fields)
478
  rnd.shuffle(fields)
479

    
480
  # Test a number of field combinations
481
  for testfields in _SelectQueryFields(rnd, fields):
482
    AssertCommand([cmd, "list", "--output", ",".join(testfields)])
483

    
484
  if namefield is not None:
485
    namelist_fn = compat.partial(_List, cmd, [namefield])
486

    
487
    # When no names were requested, the list must be sorted
488
    names = namelist_fn(None)
489
    AssertEqual(names, utils.NiceSort(names))
490

    
491
    # When requesting specific names, the order must be kept
492
    revnames = list(reversed(names))
493
    AssertEqual(namelist_fn(revnames), revnames)
494

    
495
    randnames = list(names)
496
    rnd.shuffle(randnames)
497
    AssertEqual(namelist_fn(randnames), randnames)
498

    
499
  if test_unknown:
500
    # Listing unknown items must fail
501
    AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
502
                  fail=True)
503

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

    
509

    
510
def GenericQueryFieldsTest(cmd, fields):
511
  master = qa_config.GetMasterNode()
512

    
513
  # Listing fields
514
  AssertCommand([cmd, "list-fields"])
515
  AssertCommand([cmd, "list-fields"] + fields)
516

    
517
  # Check listed fields (all, must be sorted)
518
  realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
519
  output = GetCommandOutput(master["primary"],
520
                            utils.ShellQuoteArgs(realcmd)).splitlines()
521
  AssertEqual([line.split("|", 1)[0] for line in output],
522
              utils.NiceSort(fields))
523

    
524
  # Check exit code for listing unknown field
525
  AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
526
                            fail=True),
527
              constants.EXIT_UNKNOWN_FIELD)
528

    
529

    
530
def _FormatWithColor(text, seq):
531
  if not seq:
532
    return text
533
  return "%s%s%s" % (seq, text, _RESET_SEQ)
534

    
535

    
536
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
537
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
538
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
539

    
540

    
541
def AddToEtcHosts(hostnames):
542
  """Adds hostnames to /etc/hosts.
543

544
  @param hostnames: List of hostnames first used A records, all other CNAMEs
545

546
  """
547
  master = qa_config.GetMasterNode()
548
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
549

    
550
  data = []
551
  for localhost in ("::1", "127.0.0.1"):
552
    data.append("%s %s" % (localhost, " ".join(hostnames)))
553

    
554
  try:
555
    AssertCommand("{ cat %s && echo -e '%s'; } > %s && mv %s %s" %
556
                  (utils.ShellQuote(pathutils.ETC_HOSTS),
557
                   "\\n".join(data),
558
                   utils.ShellQuote(tmp_hosts),
559
                   utils.ShellQuote(tmp_hosts),
560
                   utils.ShellQuote(pathutils.ETC_HOSTS)))
561
  except Exception:
562
    AssertCommand(["rm", "-f", tmp_hosts])
563
    raise
564

    
565

    
566
def RemoveFromEtcHosts(hostnames):
567
  """Remove hostnames from /etc/hosts.
568

569
  @param hostnames: List of hostnames first used A records, all other CNAMEs
570

571
  """
572
  master = qa_config.GetMasterNode()
573
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
574
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
575

    
576
  sed_data = " ".join(hostnames)
577
  try:
578
    AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' %s > %s"
579
                   " && mv %s %s") %
580
                   (sed_data, utils.ShellQuote(pathutils.ETC_HOSTS),
581
                    quoted_tmp_hosts, quoted_tmp_hosts,
582
                    utils.ShellQuote(pathutils.ETC_HOSTS)))
583
  except Exception:
584
    AssertCommand(["rm", "-f", tmp_hosts])
585
    raise
586

    
587

    
588
def RunInstanceCheck(instance, running):
589
  """Check if instance is running or not.
590

591
  """
592
  instance_name = _GetName(instance, "name")
593

    
594
  script = qa_config.GetInstanceCheckScript()
595
  if not script:
596
    return
597

    
598
  master_node = qa_config.GetMasterNode()
599

    
600
  # Build command to connect to master node
601
  master_ssh = GetSSHCommand(master_node["primary"], "--")
602

    
603
  if running:
604
    running_shellval = "1"
605
    running_text = ""
606
  else:
607
    running_shellval = ""
608
    running_text = "not "
609

    
610
  print FormatInfo("Checking if instance '%s' is %srunning" %
611
                   (instance_name, running_text))
612

    
613
  args = [script, instance_name]
614
  env = {
615
    "PATH": constants.HOOKS_PATH,
616
    "RUN_UUID": _RUN_UUID,
617
    "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
618
    "INSTANCE_NAME": instance_name,
619
    "INSTANCE_RUNNING": running_shellval,
620
    }
621

    
622
  result = os.spawnve(os.P_WAIT, script, args, env)
623
  if result != 0:
624
    raise qa_error.Error("Instance check failed with result %s" % result)
625

    
626

    
627
def _InstanceCheckInner(expected, instarg, args, result):
628
  """Helper function used by L{InstanceCheck}.
629

630
  """
631
  if instarg == FIRST_ARG:
632
    instance = args[0]
633
  elif instarg == RETURN_VALUE:
634
    instance = result
635
  else:
636
    raise Exception("Invalid value '%s' for instance argument" % instarg)
637

    
638
  if expected in (INST_DOWN, INST_UP):
639
    RunInstanceCheck(instance, (expected == INST_UP))
640
  elif expected is not None:
641
    raise Exception("Invalid value '%s'" % expected)
642

    
643

    
644
def InstanceCheck(before, after, instarg):
645
  """Decorator to check instance status before and after test.
646

647
  @param before: L{INST_DOWN} if instance must be stopped before test,
648
    L{INST_UP} if instance must be running before test, L{None} to not check.
649
  @param after: L{INST_DOWN} if instance must be stopped after test,
650
    L{INST_UP} if instance must be running after test, L{None} to not check.
651
  @param instarg: L{FIRST_ARG} to use first argument to test as instance (a
652
    dictionary), L{RETURN_VALUE} to use return value (disallows pre-checks)
653

654
  """
655
  def decorator(fn):
656
    @functools.wraps(fn)
657
    def wrapper(*args, **kwargs):
658
      _InstanceCheckInner(before, instarg, args, NotImplemented)
659

    
660
      result = fn(*args, **kwargs)
661

    
662
      _InstanceCheckInner(after, instarg, args, result)
663

    
664
      return result
665
    return wrapper
666
  return decorator
667

    
668

    
669
def GetNonexistentGroups(count):
670
  """Gets group names which shouldn't exist on the cluster.
671

672
  @param count: Number of groups to get
673
  @rtype: list
674

675
  """
676
  groups = qa_config.get("groups", {})
677

    
678
  default = ["group1", "group2", "group3"]
679
  assert count <= len(default)
680

    
681
  candidates = groups.get("inexistent-groups", default)[:count]
682

    
683
  if len(candidates) < count:
684
    raise Exception("At least %s non-existent groups are needed" % count)
685

    
686
  return candidates