qa_utils: Factorize code for getting entity name
[ganeti-local] / qa / qa_utils.py
1 #
2 #
3
4 # Copyright (C) 2007, 2011 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=True):
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: Bool
207   @param tty: If we should use tty
208
209   """
210   args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-l", "root"]
211
212   if tty:
213     args.append("-t")
214
215   if strict:
216     tmp = "yes"
217   else:
218     tmp = "no"
219   args.append("-oStrictHostKeyChecking=%s" % tmp)
220   args.append("-oClearAllForwardings=yes")
221   args.append("-oForwardAgent=yes")
222   if opts:
223     args.extend(opts)
224   if node in _MULTIPLEXERS:
225     spath = _MULTIPLEXERS[node][0]
226     args.append("-oControlPath=%s" % spath)
227     args.append("-oControlMaster=no")
228   args.append(node)
229   if cmd:
230     args.append(cmd)
231
232   return args
233
234
235 def StartLocalCommand(cmd, **kwargs):
236   """Starts a local command.
237
238   """
239   print "Command: %s" % utils.ShellQuoteArgs(cmd)
240   return subprocess.Popen(cmd, shell=False, **kwargs)
241
242
243 def StartSSH(node, cmd, strict=True):
244   """Starts SSH.
245
246   """
247   return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict))
248
249
250 def StartMultiplexer(node):
251   """Starts a multiplexer command.
252
253   @param node: the node for which to open the multiplexer
254
255   """
256   if node in _MULTIPLEXERS:
257     return
258
259   # Note: yes, we only need mktemp, since we'll remove the file anyway
260   sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
261   utils.RemoveFile(sname)
262   opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
263   print "Created socket at %s" % sname
264   child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
265   _MULTIPLEXERS[node] = (sname, child)
266
267
268 def CloseMultiplexers():
269   """Closes all current multiplexers and cleans up.
270
271   """
272   for node in _MULTIPLEXERS.keys():
273     (sname, child) = _MULTIPLEXERS.pop(node)
274     utils.KillProcess(child.pid, timeout=10, waitpid=True)
275     utils.RemoveFile(sname)
276
277
278 def GetCommandOutput(node, cmd, tty=True):
279   """Returns the output of a command executed on the given node.
280
281   """
282   p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
283                         stdout=subprocess.PIPE)
284   AssertEqual(p.wait(), 0)
285   return p.stdout.read()
286
287
288 def UploadFile(node, src):
289   """Uploads a file to a node and returns the filename.
290
291   Caller needs to remove the returned file on the node when it's not needed
292   anymore.
293
294   """
295   # Make sure nobody else has access to it while preserving local permissions
296   mode = os.stat(src).st_mode & 0700
297
298   cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
299          '[[ -f "${tmp}" ]] && '
300          'cat > "${tmp}" && '
301          'echo "${tmp}"') % mode
302
303   f = open(src, "r")
304   try:
305     p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
306                          stdout=subprocess.PIPE)
307     AssertEqual(p.wait(), 0)
308
309     # Return temporary filename
310     return p.stdout.read().strip()
311   finally:
312     f.close()
313
314
315 def UploadData(node, data, mode=0600, filename=None):
316   """Uploads data to a node and returns the filename.
317
318   Caller needs to remove the returned file on the node when it's not needed
319   anymore.
320
321   """
322   if filename:
323     tmp = "tmp=%s" % utils.ShellQuote(filename)
324   else:
325     tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
326   cmd = ("%s && "
327          "[[ -f \"${tmp}\" ]] && "
328          "cat > \"${tmp}\" && "
329          "echo \"${tmp}\"") % tmp
330
331   p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
332                        stdin=subprocess.PIPE, stdout=subprocess.PIPE)
333   p.stdin.write(data)
334   p.stdin.close()
335   AssertEqual(p.wait(), 0)
336
337   # Return temporary filename
338   return p.stdout.read().strip()
339
340
341 def BackupFile(node, path):
342   """Creates a backup of a file on the node and returns the filename.
343
344   Caller needs to remove the returned file on the node when it's not needed
345   anymore.
346
347   """
348   cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
349          "[[ -f \"$tmp\" ]] && "
350          "cp %s $tmp && "
351          "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
352
353   # Return temporary filename
354   return GetCommandOutput(node, cmd).strip()
355
356
357 def _ResolveName(cmd, key):
358   """Helper function.
359
360   """
361   master = qa_config.GetMasterNode()
362
363   output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
364   for line in output.splitlines():
365     (lkey, lvalue) = line.split(":", 1)
366     if lkey == key:
367       return lvalue.lstrip()
368   raise KeyError("Key not found")
369
370
371 def ResolveInstanceName(instance):
372   """Gets the full name of an instance.
373
374   @type instance: string
375   @param instance: Instance name
376
377   """
378   return _ResolveName(["gnt-instance", "info", instance],
379                       "Instance name")
380
381
382 def ResolveNodeName(node):
383   """Gets the full name of a node.
384
385   """
386   return _ResolveName(["gnt-node", "info", node["primary"]],
387                       "Node name")
388
389
390 def GetNodeInstances(node, secondaries=False):
391   """Gets a list of instances on a node.
392
393   """
394   master = qa_config.GetMasterNode()
395   node_name = ResolveNodeName(node)
396
397   # Get list of all instances
398   cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
399          "--output=name,pnode,snodes"]
400   output = GetCommandOutput(master["primary"], utils.ShellQuoteArgs(cmd))
401
402   instances = []
403   for line in output.splitlines():
404     (name, pnode, snodes) = line.split(":", 2)
405     if ((not secondaries and pnode == node_name) or
406         (secondaries and node_name in snodes.split(","))):
407       instances.append(name)
408
409   return instances
410
411
412 def _SelectQueryFields(rnd, fields):
413   """Generates a list of fields for query tests.
414
415   """
416   # Create copy for shuffling
417   fields = list(fields)
418   rnd.shuffle(fields)
419
420   # Check all fields
421   yield fields
422   yield sorted(fields)
423
424   # Duplicate fields
425   yield fields + fields
426
427   # Check small groups of fields
428   while fields:
429     yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
430
431
432 def _List(listcmd, fields, names):
433   """Runs a list command.
434
435   """
436   master = qa_config.GetMasterNode()
437
438   cmd = [listcmd, "list", "--separator=|", "--no-headers",
439          "--output", ",".join(fields)]
440
441   if names:
442     cmd.extend(names)
443
444   return GetCommandOutput(master["primary"],
445                           utils.ShellQuoteArgs(cmd)).splitlines()
446
447
448 def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
449   """Runs a number of tests on query commands.
450
451   @param cmd: Command name
452   @param fields: List of field names
453
454   """
455   rnd = random.Random(hash(cmd))
456
457   fields = list(fields)
458   rnd.shuffle(fields)
459
460   # Test a number of field combinations
461   for testfields in _SelectQueryFields(rnd, fields):
462     AssertCommand([cmd, "list", "--output", ",".join(testfields)])
463
464   if namefield is not None:
465     namelist_fn = compat.partial(_List, cmd, [namefield])
466
467     # When no names were requested, the list must be sorted
468     names = namelist_fn(None)
469     AssertEqual(names, utils.NiceSort(names))
470
471     # When requesting specific names, the order must be kept
472     revnames = list(reversed(names))
473     AssertEqual(namelist_fn(revnames), revnames)
474
475     randnames = list(names)
476     rnd.shuffle(randnames)
477     AssertEqual(namelist_fn(randnames), randnames)
478
479   if test_unknown:
480     # Listing unknown items must fail
481     AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
482                   fail=True)
483
484   # Check exit code for listing unknown field
485   AssertEqual(AssertCommand([cmd, "list", "--output=field/does/not/exist"],
486                             fail=True),
487               constants.EXIT_UNKNOWN_FIELD)
488
489
490 def GenericQueryFieldsTest(cmd, fields):
491   master = qa_config.GetMasterNode()
492
493   # Listing fields
494   AssertCommand([cmd, "list-fields"])
495   AssertCommand([cmd, "list-fields"] + fields)
496
497   # Check listed fields (all, must be sorted)
498   realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
499   output = GetCommandOutput(master["primary"],
500                             utils.ShellQuoteArgs(realcmd)).splitlines()
501   AssertEqual([line.split("|", 1)[0] for line in output],
502               utils.NiceSort(fields))
503
504   # Check exit code for listing unknown field
505   AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
506                             fail=True),
507               constants.EXIT_UNKNOWN_FIELD)
508
509
510 def _FormatWithColor(text, seq):
511   if not seq:
512     return text
513   return "%s%s%s" % (seq, text, _RESET_SEQ)
514
515
516 FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
517 FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
518 FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
519
520
521 def AddToEtcHosts(hostnames):
522   """Adds hostnames to /etc/hosts.
523
524   @param hostnames: List of hostnames first used A records, all other CNAMEs
525
526   """
527   master = qa_config.GetMasterNode()
528   tmp_hosts = UploadData(master["primary"], "", mode=0644)
529
530   quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
531   data = []
532   for localhost in ("::1", "127.0.0.1"):
533     data.append("%s %s" % (localhost, " ".join(hostnames)))
534
535   try:
536     AssertCommand(("cat /etc/hosts > %s && echo -e '%s' >> %s && mv %s"
537                    " /etc/hosts") % (quoted_tmp_hosts, "\\n".join(data),
538                                      quoted_tmp_hosts, quoted_tmp_hosts))
539   except qa_error.Error:
540     AssertCommand(["rm", tmp_hosts])
541
542
543 def RemoveFromEtcHosts(hostnames):
544   """Remove hostnames from /etc/hosts.
545
546   @param hostnames: List of hostnames first used A records, all other CNAMEs
547
548   """
549   master = qa_config.GetMasterNode()
550   tmp_hosts = UploadData(master["primary"], "", mode=0644)
551   quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
552
553   sed_data = " ".join(hostnames)
554   try:
555     AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' /etc/hosts > %s"
556                    " && mv %s /etc/hosts") % (sed_data, quoted_tmp_hosts,
557                                               quoted_tmp_hosts))
558   except qa_error.Error:
559     AssertCommand(["rm", tmp_hosts])
560
561
562 def RunInstanceCheck(instance, running):
563   """Check if instance is running or not.
564
565   """
566   instance_name = _GetName(instance, "name")
567
568   script = qa_config.GetInstanceCheckScript()
569   if not script:
570     return
571
572   master_node = qa_config.GetMasterNode()
573
574   # Build command to connect to master node
575   master_ssh = GetSSHCommand(master_node["primary"], "--")
576
577   if running:
578     running_shellval = "1"
579     running_text = ""
580   else:
581     running_shellval = ""
582     running_text = "not "
583
584   print FormatInfo("Checking if instance '%s' is %srunning" %
585                    (instance_name, running_text))
586
587   args = [script, instance_name]
588   env = {
589     "PATH": constants.HOOKS_PATH,
590     "RUN_UUID": _RUN_UUID,
591     "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
592     "INSTANCE_NAME": instance_name,
593     "INSTANCE_RUNNING": running_shellval,
594     }
595
596   result = os.spawnve(os.P_WAIT, script, args, env)
597   if result != 0:
598     raise qa_error.Error("Instance check failed with result %s" % result)
599
600
601 def _InstanceCheckInner(expected, instarg, args, result):
602   """Helper function used by L{InstanceCheck}.
603
604   """
605   if instarg == FIRST_ARG:
606     instance = args[0]
607   elif instarg == RETURN_VALUE:
608     instance = result
609   else:
610     raise Exception("Invalid value '%s' for instance argument" % instarg)
611
612   if expected in (INST_DOWN, INST_UP):
613     RunInstanceCheck(instance, (expected == INST_UP))
614   elif expected is not None:
615     raise Exception("Invalid value '%s'" % expected)
616
617
618 def InstanceCheck(before, after, instarg):
619   """Decorator to check instance status before and after test.
620
621   @param before: L{INST_DOWN} if instance must be stopped before test,
622     L{INST_UP} if instance must be running before test, L{None} to not check.
623   @param after: L{INST_DOWN} if instance must be stopped after test,
624     L{INST_UP} if instance must be running after test, L{None} to not check.
625   @param instarg: L{FIRST_ARG} to use first argument to test as instance (a
626     dictionary), L{RETURN_VALUE} to use return value (disallows pre-checks)
627
628   """
629   def decorator(fn):
630     @functools.wraps(fn)
631     def wrapper(*args, **kwargs):
632       _InstanceCheckInner(before, instarg, args, NotImplemented)
633
634       result = fn(*args, **kwargs)
635
636       _InstanceCheckInner(after, instarg, args, result)
637
638       return result
639     return wrapper
640   return decorator