Statistics
| Branch: | Tag: | Revision:

root / qa / qa_utils.py @ 587f8ff6

History | View | Annotate | Download (17.9 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):
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
  @return: the return code of the command
174
  @raise qa_error.Error: if the command fails when it shouldn't or vice versa
175

176
  """
177
  if node is None:
178
    node = qa_config.GetMasterNode()
179

    
180
  nodename = _GetName(node, "primary")
181

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

    
187
  rcode = StartSSH(nodename, cmdstr).wait()
188
  _AssertRetCode(rcode, fail, cmdstr, nodename)
189

    
190
  return rcode
191

    
192

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

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

208
  """
209
  args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
210

    
211
  if tty is None:
212
    tty = sys.stdout.isatty()
213

    
214
  if tty:
215
    args.append("-t")
216

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

    
234
  return args
235

    
236

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

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

    
248

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

252
  """
253
  return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
254
                           _nolog_opts=True)
255

    
256

    
257
def StartMultiplexer(node):
258
  """Starts a multiplexer command.
259

260
  @param node: the node for which to open the multiplexer
261

262
  """
263
  if node in _MULTIPLEXERS:
264
    return
265

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

    
274

    
275
def CloseMultiplexers():
276
  """Closes all current multiplexers and cleans up.
277

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

    
284

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

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

    
304

    
305
def UploadFile(node, src):
306
  """Uploads a file to a node and returns the filename.
307

308
  Caller needs to remove the returned file on the node when it's not needed
309
  anymore.
310

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

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

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

    
326
    # Return temporary filename
327
    return p.stdout.read().strip()
328
  finally:
329
    f.close()
330

    
331

    
332
def UploadData(node, data, mode=0600, filename=None):
333
  """Uploads data to a node and returns the filename.
334

335
  Caller needs to remove the returned file on the node when it's not needed
336
  anymore.
337

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

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

    
354
  # Return temporary filename
355
  return p.stdout.read().strip()
356

    
357

    
358
def BackupFile(node, path):
359
  """Creates a backup of a file on the node and returns the filename.
360

361
  Caller needs to remove the returned file on the node when it's not needed
362
  anymore.
363

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

    
370
  # Return temporary filename
371
  return GetCommandOutput(node, cmd).strip()
372

    
373

    
374
def _ResolveName(cmd, key):
375
  """Helper function.
376

377
  """
378
  master = qa_config.GetMasterNode()
379

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

    
387

    
388
def ResolveInstanceName(instance):
389
  """Gets the full name of an instance.
390

391
  @type instance: string
392
  @param instance: Instance name
393

394
  """
395
  return _ResolveName(["gnt-instance", "info", instance],
396
                      "Instance name")
397

    
398

    
399
def ResolveNodeName(node):
400
  """Gets the full name of a node.
401

402
  """
403
  return _ResolveName(["gnt-node", "info", node["primary"]],
404
                      "Node name")
405

    
406

    
407
def GetNodeInstances(node, secondaries=False):
408
  """Gets a list of instances on a node.
409

410
  """
411
  master = qa_config.GetMasterNode()
412
  node_name = ResolveNodeName(node)
413

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

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

    
426
  return instances
427

    
428

    
429
def _SelectQueryFields(rnd, fields):
430
  """Generates a list of fields for query tests.
431

432
  """
433
  # Create copy for shuffling
434
  fields = list(fields)
435
  rnd.shuffle(fields)
436

    
437
  # Check all fields
438
  yield fields
439
  yield sorted(fields)
440

    
441
  # Duplicate fields
442
  yield fields + fields
443

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

    
448

    
449
def _List(listcmd, fields, names):
450
  """Runs a list command.
451

452
  """
453
  master = qa_config.GetMasterNode()
454

    
455
  cmd = [listcmd, "list", "--separator=|", "--no-headers",
456
         "--output", ",".join(fields)]
457

    
458
  if names:
459
    cmd.extend(names)
460

    
461
  return GetCommandOutput(master["primary"],
462
                          utils.ShellQuoteArgs(cmd)).splitlines()
463

    
464

    
465
def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
466
  """Runs a number of tests on query commands.
467

468
  @param cmd: Command name
469
  @param fields: List of field names
470

471
  """
472
  rnd = random.Random(hash(cmd))
473

    
474
  fields = list(fields)
475
  rnd.shuffle(fields)
476

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

    
481
  if namefield is not None:
482
    namelist_fn = compat.partial(_List, cmd, [namefield])
483

    
484
    # When no names were requested, the list must be sorted
485
    names = namelist_fn(None)
486
    AssertEqual(names, utils.NiceSort(names))
487

    
488
    # When requesting specific names, the order must be kept
489
    revnames = list(reversed(names))
490
    AssertEqual(namelist_fn(revnames), revnames)
491

    
492
    randnames = list(names)
493
    rnd.shuffle(randnames)
494
    AssertEqual(namelist_fn(randnames), randnames)
495

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

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

    
506

    
507
def GenericQueryFieldsTest(cmd, fields):
508
  master = qa_config.GetMasterNode()
509

    
510
  # Listing fields
511
  AssertCommand([cmd, "list-fields"])
512
  AssertCommand([cmd, "list-fields"] + fields)
513

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

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

    
526

    
527
def _FormatWithColor(text, seq):
528
  if not seq:
529
    return text
530
  return "%s%s%s" % (seq, text, _RESET_SEQ)
531

    
532

    
533
FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
534
FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
535
FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
536

    
537

    
538
def AddToEtcHosts(hostnames):
539
  """Adds hostnames to /etc/hosts.
540

541
  @param hostnames: List of hostnames first used A records, all other CNAMEs
542

543
  """
544
  master = qa_config.GetMasterNode()
545
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
546

    
547
  data = []
548
  for localhost in ("::1", "127.0.0.1"):
549
    data.append("%s %s" % (localhost, " ".join(hostnames)))
550

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

    
562

    
563
def RemoveFromEtcHosts(hostnames):
564
  """Remove hostnames from /etc/hosts.
565

566
  @param hostnames: List of hostnames first used A records, all other CNAMEs
567

568
  """
569
  master = qa_config.GetMasterNode()
570
  tmp_hosts = UploadData(master["primary"], "", mode=0644)
571
  quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
572

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

    
584

    
585
def RunInstanceCheck(instance, running):
586
  """Check if instance is running or not.
587

588
  """
589
  instance_name = _GetName(instance, "name")
590

    
591
  script = qa_config.GetInstanceCheckScript()
592
  if not script:
593
    return
594

    
595
  master_node = qa_config.GetMasterNode()
596

    
597
  # Build command to connect to master node
598
  master_ssh = GetSSHCommand(master_node["primary"], "--")
599

    
600
  if running:
601
    running_shellval = "1"
602
    running_text = ""
603
  else:
604
    running_shellval = ""
605
    running_text = "not "
606

    
607
  print FormatInfo("Checking if instance '%s' is %srunning" %
608
                   (instance_name, running_text))
609

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

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

    
623

    
624
def _InstanceCheckInner(expected, instarg, args, result):
625
  """Helper function used by L{InstanceCheck}.
626

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

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

    
640

    
641
def InstanceCheck(before, after, instarg):
642
  """Decorator to check instance status before and after test.
643

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

651
  """
652
  def decorator(fn):
653
    @functools.wraps(fn)
654
    def wrapper(*args, **kwargs):
655
      _InstanceCheckInner(before, instarg, args, NotImplemented)
656

    
657
      result = fn(*args, **kwargs)
658

    
659
      _InstanceCheckInner(after, instarg, args, result)
660

    
661
      return result
662
    return wrapper
663
  return decorator
664

    
665

    
666
def GetNonexistentGroups(count):
667
  """Gets group names which shouldn't exist on the cluster.
668

669
  @param count: Number of groups to get
670
  @rtype: list
671

672
  """
673
  groups = qa_config.get("groups", {})
674

    
675
  default = ["group1", "group2", "group3"]
676
  assert count <= len(default)
677

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

    
680
  if len(candidates) < count:
681
    raise Exception("At least %s non-existent groups are needed" % count)
682

    
683
  return candidates