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