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