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