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