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