Implement option to skip logging of QA commands
[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
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