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