HTools/Types.hs: more auto-repair types
[ganeti-local] / qa / qa_utils.py
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 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 AssertCommand(cmd, fail=False, node=None):
152   """Checks that a remote command succeeds.
153
154   @param cmd: either a string (the command to execute) or a list (to
155       be converted using L{utils.ShellQuoteArgs} into a string)
156   @type fail: boolean
157   @param fail: if the command is expected to fail instead of succeeding
158   @param node: if passed, it should be the node on which the command
159       should be executed, instead of the master node (can be either a
160       dict or a string)
161   @return: the return code of the command
162   @raise qa_error.Error: if the command fails when it shouldn't or vice versa
163
164   """
165   if node is None:
166     node = qa_config.GetMasterNode()
167
168   nodename = _GetName(node, "primary")
169
170   if isinstance(cmd, basestring):
171     cmdstr = cmd
172   else:
173     cmdstr = utils.ShellQuoteArgs(cmd)
174
175   rcode = StartSSH(nodename, cmdstr).wait()
176
177   if fail:
178     if rcode == 0:
179       raise qa_error.Error("Command '%s' on node %s was expected to fail but"
180                            " didn't" % (cmdstr, nodename))
181   else:
182     if rcode != 0:
183       raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
184                            (cmdstr, nodename, rcode))
185
186   return rcode
187
188
189 def GetSSHCommand(node, cmd, strict=True, opts=None, tty=None):
190   """Builds SSH command to be executed.
191
192   @type node: string
193   @param node: node the command should run on
194   @type cmd: string
195   @param cmd: command to be executed in the node; if None or empty
196       string, no command will be executed
197   @type strict: boolean
198   @param strict: whether to enable strict host key checking
199   @type opts: list
200   @param opts: list of additional options
201   @type tty: boolean or None
202   @param tty: if we should use tty; if None, will be auto-detected
203
204   """
205   args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
206
207   if tty is None:
208     tty = sys.stdout.isatty()
209
210   if tty:
211     args.append("-t")
212
213   if strict:
214     tmp = "yes"
215   else:
216     tmp = "no"
217   args.append("-oStrictHostKeyChecking=%s" % tmp)
218   args.append("-oClearAllForwardings=yes")
219   args.append("-oForwardAgent=yes")
220   if opts:
221     args.extend(opts)
222   if node in _MULTIPLEXERS:
223     spath = _MULTIPLEXERS[node][0]
224     args.append("-oControlPath=%s" % spath)
225     args.append("-oControlMaster=no")
226   args.append(node)
227   if cmd:
228     args.append(cmd)
229
230   return args
231
232
233 def StartLocalCommand(cmd, _nolog_opts=False, **kwargs):
234   """Starts a local command.
235
236   """
237   if _nolog_opts:
238     pcmd = [i for i in cmd if not i.startswith("-")]
239   else:
240     pcmd = cmd
241   print "Command: %s" % utils.ShellQuoteArgs(pcmd)
242   return subprocess.Popen(cmd, shell=False, **kwargs)
243
244
245 def StartSSH(node, cmd, strict=True):
246   """Starts SSH.
247
248   """
249   return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
250                            _nolog_opts=True)
251
252
253 def StartMultiplexer(node):
254   """Starts a multiplexer command.
255
256   @param node: the node for which to open the multiplexer
257
258   """
259   if node in _MULTIPLEXERS:
260     return
261
262   # Note: yes, we only need mktemp, since we'll remove the file anyway
263   sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
264   utils.RemoveFile(sname)
265   opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
266   print "Created socket at %s" % sname
267   child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
268   _MULTIPLEXERS[node] = (sname, child)
269
270
271 def CloseMultiplexers():
272   """Closes all current multiplexers and cleans up.
273
274   """
275   for node in _MULTIPLEXERS.keys():
276     (sname, child) = _MULTIPLEXERS.pop(node)
277     utils.KillProcess(child.pid, timeout=10, waitpid=True)
278     utils.RemoveFile(sname)
279
280
281 def GetCommandOutput(node, cmd, tty=None):
282   """Returns the output of a command executed on the given node.
283
284   """
285   p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
286                         stdout=subprocess.PIPE)
287   AssertEqual(p.wait(), 0)
288   return p.stdout.read()
289
290
291 def UploadFile(node, src):
292   """Uploads a file to a node and returns the filename.
293
294   Caller needs to remove the returned file on the node when it's not needed
295   anymore.
296
297   """
298   # Make sure nobody else has access to it while preserving local permissions
299   mode = os.stat(src).st_mode & 0700
300
301   cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
302          '[[ -f "${tmp}" ]] && '
303          'cat > "${tmp}" && '
304          'echo "${tmp}"') % mode
305
306   f = open(src, "r")
307   try:
308     p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
309                          stdout=subprocess.PIPE)
310     AssertEqual(p.wait(), 0)
311
312     # Return temporary filename
313     return p.stdout.read().strip()
314   finally:
315     f.close()
316
317
318 def UploadData(node, data, mode=0600, filename=None):
319   """Uploads data to a node and returns the filename.
320
321   Caller needs to remove the returned file on the node when it's not needed
322   anymore.
323
324   """
325   if filename:
326     tmp = "tmp=%s" % utils.ShellQuote(filename)
327   else:
328     tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
329   cmd = ("%s && "
330          "[[ -f \"${tmp}\" ]] && "
331          "cat > \"${tmp}\" && "
332          "echo \"${tmp}\"") % tmp
333
334   p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
335                        stdin=subprocess.PIPE, stdout=subprocess.PIPE)
336   p.stdin.write(data)
337   p.stdin.close()
338   AssertEqual(p.wait(), 0)
339
340   # Return temporary filename
341   return p.stdout.read().strip()
342
343
344 def BackupFile(node, path):
345   """Creates a backup of a file on the node and returns the filename.
346
347   Caller needs to remove the returned file on the node when it's not needed
348   anymore.
349
350   """
351   cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
352          "[[ -f \"$tmp\" ]] && "
353          "cp %s $tmp && "
354          "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
355
356   # Return temporary filename
357   return GetCommandOutput(node, cmd).strip()
358
359
360 def _ResolveName(cmd, key):
361   """Helper function.
362
363   """
364   master = qa_config.GetMasterNode()
365
366   output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
367   for line in output.splitlines():
368     (lkey, lvalue) = line.split(":", 1)
369     if lkey == key:
370       return lvalue.lstrip()
371   raise KeyError("Key not found")
372
373
374 def ResolveInstanceName(instance):
375   """Gets the full name of an instance.
376
377   @type instance: string
378   @param instance: Instance name
379
380   """
381   return _ResolveName(["gnt-instance", "info", instance],
382                       "Instance name")
383
384
385 def ResolveNodeName(node):
386   """Gets the full name of a node.
387
388   """
389   return _ResolveName(["gnt-node", "info", node["primary"]],
390                       "Node name")
391
392
393 def GetNodeInstances(node, secondaries=False):
394   """Gets a list of instances on a node.
395
396   """
397   master = qa_config.GetMasterNode()
398   node_name = ResolveNodeName(node)
399
400   # Get list of all instances
401   cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
402          "--output=name,pnode,snodes"]
403   output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
404
405   instances = []
406   for line in output.splitlines():
407     (name, pnode, snodes) = line.split(":", 2)
408     if ((not secondaries and pnode == node_name) or
409         (secondaries and node_name in snodes.split(","))):
410       instances.append(name)
411
412   return instances
413
414
415 def _SelectQueryFields(rnd, fields):
416   """Generates a list of fields for query tests.
417
418   """
419   # Create copy for shuffling
420   fields = list(fields)
421   rnd.shuffle(fields)
422
423   # Check all fields
424   yield fields
425   yield sorted(fields)
426
427   # Duplicate fields
428   yield fields + fields
429
430   # Check small groups of fields
431   while fields:
432     yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
433
434
435 def _List(listcmd, fields, names):
436   """Runs a list command.
437
438   """
439   master = qa_config.GetMasterNode()
440
441   cmd = [listcmd, "list", "--separator=|", "--no-headers",
442          "--output", ",".join(fields)]
443
444   if names:
445     cmd.extend(names)
446
447   return GetCommandOutput(master["primary"],
448                           utils.ShellQuoteArgs(cmd)).splitlines()
449
450
451 def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
452   """Runs a number of tests on query commands.
453
454   @param cmd: Command name
455   @param fields: List of field names
456
457   """
458   rnd = random.Random(hash(cmd))
459
460   fields = list(fields)
461   rnd.shuffle(fields)
462
463   # Test a number of field combinations
464   for testfields in _SelectQueryFields(rnd, fields):
465     AssertCommand([cmd, "list", "--output", ",".join(testfields)])
466
467   if namefield is not None:
468     namelist_fn = compat.partial(_List, cmd, [namefield])
469
470     # When no names were requested, the list must be sorted
471     names = namelist_fn(None)
472     AssertEqual(names, utils.NiceSort(names))
473
474     # When requesting specific names, the order must be kept
475     revnames = list(reversed(names))
476     AssertEqual(namelist_fn(revnames), revnames)
477
478     randnames = list(names)
479     rnd.shuffle(randnames)
480     AssertEqual(namelist_fn(randnames), randnames)
481
482   if test_unknown:
483     # Listing unknown items must fail
484     AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
485                   fail=True)
486
487   # Check exit code for listing unknown field
488   AssertEqual(AssertCommand([cmd, "list", "--output=field/does/not/exist"],
489                             fail=True),
490               constants.EXIT_UNKNOWN_FIELD)
491
492
493 def GenericQueryFieldsTest(cmd, fields):
494   master = qa_config.GetMasterNode()
495
496   # Listing fields
497   AssertCommand([cmd, "list-fields"])
498   AssertCommand([cmd, "list-fields"] + fields)
499
500   # Check listed fields (all, must be sorted)
501   realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
502   output = GetCommandOutput(master["primary"],
503                             utils.ShellQuoteArgs(realcmd)).splitlines()
504   AssertEqual([line.split("|", 1)[0] for line in output],
505               utils.NiceSort(fields))
506
507   # Check exit code for listing unknown field
508   AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
509                             fail=True),
510               constants.EXIT_UNKNOWN_FIELD)
511
512
513 def _FormatWithColor(text, seq):
514   if not seq:
515     return text
516   return "%s%s%s" % (seq, text, _RESET_SEQ)
517
518
519 FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
520 FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
521 FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
522
523
524 def AddToEtcHosts(hostnames):
525   """Adds hostnames to /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
533   data = []
534   for localhost in ("::1", "127.0.0.1"):
535     data.append("%s %s" % (localhost, " ".join(hostnames)))
536
537   try:
538     AssertCommand("{ cat %s && echo -e '%s'; } > %s && mv %s %s" %
539                   (utils.ShellQuote(pathutils.ETC_HOSTS),
540                    "\\n".join(data),
541                    utils.ShellQuote(tmp_hosts),
542                    utils.ShellQuote(tmp_hosts),
543                    utils.ShellQuote(pathutils.ETC_HOSTS)))
544   except Exception:
545     AssertCommand(["rm", "-f", tmp_hosts])
546     raise
547
548
549 def RemoveFromEtcHosts(hostnames):
550   """Remove hostnames from /etc/hosts.
551
552   @param hostnames: List of hostnames first used A records, all other CNAMEs
553
554   """
555   master = qa_config.GetMasterNode()
556   tmp_hosts = UploadData(master["primary"], "", mode=0644)
557   quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
558
559   sed_data = " ".join(hostnames)
560   try:
561     AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' %s > %s"
562                    " && mv %s %s") %
563                    (sed_data, utils.ShellQuote(pathutils.ETC_HOSTS),
564                     quoted_tmp_hosts, quoted_tmp_hosts,
565                     utils.ShellQuote(pathutils.ETC_HOSTS)))
566   except Exception:
567     AssertCommand(["rm", "-f", tmp_hosts])
568     raise
569
570
571 def RunInstanceCheck(instance, running):
572   """Check if instance is running or not.
573
574   """
575   instance_name = _GetName(instance, "name")
576
577   script = qa_config.GetInstanceCheckScript()
578   if not script:
579     return
580
581   master_node = qa_config.GetMasterNode()
582
583   # Build command to connect to master node
584   master_ssh = GetSSHCommand(master_node["primary"], "--")
585
586   if running:
587     running_shellval = "1"
588     running_text = ""
589   else:
590     running_shellval = ""
591     running_text = "not "
592
593   print FormatInfo("Checking if instance '%s' is %srunning" %
594                    (instance_name, running_text))
595
596   args = [script, instance_name]
597   env = {
598     "PATH": constants.HOOKS_PATH,
599     "RUN_UUID": _RUN_UUID,
600     "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
601     "INSTANCE_NAME": instance_name,
602     "INSTANCE_RUNNING": running_shellval,
603     }
604
605   result = os.spawnve(os.P_WAIT, script, args, env)
606   if result != 0:
607     raise qa_error.Error("Instance check failed with result %s" % result)
608
609
610 def _InstanceCheckInner(expected, instarg, args, result):
611   """Helper function used by L{InstanceCheck}.
612
613   """
614   if instarg == FIRST_ARG:
615     instance = args[0]
616   elif instarg == RETURN_VALUE:
617     instance = result
618   else:
619     raise Exception("Invalid value '%s' for instance argument" % instarg)
620
621   if expected in (INST_DOWN, INST_UP):
622     RunInstanceCheck(instance, (expected == INST_UP))
623   elif expected is not None:
624     raise Exception("Invalid value '%s'" % expected)
625
626
627 def InstanceCheck(before, after, instarg):
628   """Decorator to check instance status before and after test.
629
630   @param before: L{INST_DOWN} if instance must be stopped before test,
631     L{INST_UP} if instance must be running before test, L{None} to not check.
632   @param after: L{INST_DOWN} if instance must be stopped after test,
633     L{INST_UP} if instance must be running after test, L{None} to not check.
634   @param instarg: L{FIRST_ARG} to use first argument to test as instance (a
635     dictionary), L{RETURN_VALUE} to use return value (disallows pre-checks)
636
637   """
638   def decorator(fn):
639     @functools.wraps(fn)
640     def wrapper(*args, **kwargs):
641       _InstanceCheckInner(before, instarg, args, NotImplemented)
642
643       result = fn(*args, **kwargs)
644
645       _InstanceCheckInner(after, instarg, args, result)
646
647       return result
648     return wrapper
649   return decorator
650
651
652 def GetNonexistentGroups(count):
653   """Gets group names which shouldn't exist on the cluster.
654
655   @param count: Number of groups to get
656   @rtype: list
657
658   """
659   groups = qa_config.get("groups", {})
660
661   default = ["group1", "group2", "group3"]
662   assert count <= len(default)
663
664   candidates = groups.get("inexistent-groups", default)[:count]
665
666   if len(candidates) < count:
667     raise Exception("At least %s non-existent groups are needed" % count)
668
669   return candidates