LUInstanceRename: Fail if renamed hostname mismatch
[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 from ganeti import utils
34 from ganeti import compat
35 from ganeti import constants
36
37 import qa_config
38 import qa_error
39
40
41 _INFO_SEQ = None
42 _WARNING_SEQ = None
43 _ERROR_SEQ = None
44 _RESET_SEQ = None
45
46 _MULTIPLEXERS = {}
47
48
49 def _SetupColours():
50   """Initializes the colour constants.
51
52   """
53   # pylint: disable-msg=W0603
54   # due to global usage
55   global _INFO_SEQ, _WARNING_SEQ, _ERROR_SEQ, _RESET_SEQ
56
57   # Don't use colours if stdout isn't a terminal
58   if not sys.stdout.isatty():
59     return
60
61   try:
62     import curses
63   except ImportError:
64     # Don't use colours if curses module can't be imported
65     return
66
67   curses.setupterm()
68
69   _RESET_SEQ = curses.tigetstr("op")
70
71   setaf = curses.tigetstr("setaf")
72   _INFO_SEQ = curses.tparm(setaf, curses.COLOR_GREEN)
73   _WARNING_SEQ = curses.tparm(setaf, curses.COLOR_YELLOW)
74   _ERROR_SEQ = curses.tparm(setaf, curses.COLOR_RED)
75
76
77 _SetupColours()
78
79
80 def AssertIn(item, sequence):
81   """Raises an error when item is not in sequence.
82
83   """
84   if item not in sequence:
85     raise qa_error.Error('%r not in %r' % (item, sequence))
86
87
88 def AssertEqual(first, second):
89   """Raises an error when values aren't equal.
90
91   """
92   if not first == second:
93     raise qa_error.Error('%r == %r' % (first, second))
94
95
96 def AssertNotEqual(first, second):
97   """Raises an error when values are equal.
98
99   """
100   if not first != second:
101     raise qa_error.Error('%r != %r' % (first, second))
102
103
104 def AssertMatch(string, pattern):
105   """Raises an error when string doesn't match regexp pattern.
106
107   """
108   if not re.match(pattern, string):
109     raise qa_error.Error("%r doesn't match /%r/" % (string, pattern))
110
111
112 def AssertCommand(cmd, fail=False, node=None):
113   """Checks that a remote command succeeds.
114
115   @param cmd: either a string (the command to execute) or a list (to
116       be converted using L{utils.ShellQuoteArgs} into a string)
117   @type fail: boolean
118   @param fail: if the command is expected to fail instead of succeeding
119   @param node: if passed, it should be the node on which the command
120       should be executed, instead of the master node (can be either a
121       dict or a string)
122
123   """
124   if node is None:
125     node = qa_config.GetMasterNode()
126
127   if isinstance(node, basestring):
128     nodename = node
129   else:
130     nodename = node["primary"]
131
132   if isinstance(cmd, basestring):
133     cmdstr = cmd
134   else:
135     cmdstr = utils.ShellQuoteArgs(cmd)
136
137   rcode = StartSSH(nodename, cmdstr).wait()
138
139   if fail:
140     if rcode == 0:
141       raise qa_error.Error("Command '%s' on node %s was expected to fail but"
142                            " didn't" % (cmdstr, nodename))
143   else:
144     if rcode != 0:
145       raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
146                            (cmdstr, nodename, rcode))
147
148   return rcode
149
150
151 def GetSSHCommand(node, cmd, strict=True, opts=None, tty=True):
152   """Builds SSH command to be executed.
153
154   @type node: string
155   @param node: node the command should run on
156   @type cmd: string
157   @param cmd: command to be executed in the node; if None or empty
158       string, no command will be executed
159   @type strict: boolean
160   @param strict: whether to enable strict host key checking
161   @type opts: list
162   @param opts: list of additional options
163   @type tty: Bool
164   @param tty: If we should use tty
165
166   """
167   args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-l", "root"]
168
169   if tty:
170     args.append("-t")
171
172   if strict:
173     tmp = 'yes'
174   else:
175     tmp = 'no'
176   args.append('-oStrictHostKeyChecking=%s' % tmp)
177   args.append('-oClearAllForwardings=yes')
178   args.append('-oForwardAgent=yes')
179   if opts:
180     args.extend(opts)
181   if node in _MULTIPLEXERS:
182     spath = _MULTIPLEXERS[node][0]
183     args.append('-oControlPath=%s' % spath)
184     args.append('-oControlMaster=no')
185   args.append(node)
186   if cmd:
187     args.append(cmd)
188
189   return args
190
191
192 def StartLocalCommand(cmd, **kwargs):
193   """Starts a local command.
194
195   """
196   print "Command: %s" % utils.ShellQuoteArgs(cmd)
197   return subprocess.Popen(cmd, shell=False, **kwargs)
198
199
200 def StartSSH(node, cmd, strict=True):
201   """Starts SSH.
202
203   """
204   return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict))
205
206
207 def StartMultiplexer(node):
208   """Starts a multiplexer command.
209
210   @param node: the node for which to open the multiplexer
211
212   """
213   if node in _MULTIPLEXERS:
214     return
215
216   # Note: yes, we only need mktemp, since we'll remove the file anyway
217   sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
218   utils.RemoveFile(sname)
219   opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
220   print "Created socket at %s" % sname
221   child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
222   _MULTIPLEXERS[node] = (sname, child)
223
224
225 def CloseMultiplexers():
226   """Closes all current multiplexers and cleans up.
227
228   """
229   for node in _MULTIPLEXERS.keys():
230     (sname, child) = _MULTIPLEXERS.pop(node)
231     utils.KillProcess(child.pid, timeout=10, waitpid=True)
232     utils.RemoveFile(sname)
233
234
235 def GetCommandOutput(node, cmd, tty=True):
236   """Returns the output of a command executed on the given node.
237
238   """
239   p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
240                         stdout=subprocess.PIPE)
241   AssertEqual(p.wait(), 0)
242   return p.stdout.read()
243
244
245 def UploadFile(node, src):
246   """Uploads a file to a node and returns the filename.
247
248   Caller needs to remove the returned file on the node when it's not needed
249   anymore.
250
251   """
252   # Make sure nobody else has access to it while preserving local permissions
253   mode = os.stat(src).st_mode & 0700
254
255   cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
256          '[[ -f "${tmp}" ]] && '
257          'cat > "${tmp}" && '
258          'echo "${tmp}"') % mode
259
260   f = open(src, 'r')
261   try:
262     p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
263                          stdout=subprocess.PIPE)
264     AssertEqual(p.wait(), 0)
265
266     # Return temporary filename
267     return p.stdout.read().strip()
268   finally:
269     f.close()
270
271
272 def UploadData(node, data, mode=0600, filename=None):
273   """Uploads data to a node and returns the filename.
274
275   Caller needs to remove the returned file on the node when it's not needed
276   anymore.
277
278   """
279   if filename:
280     tmp = "tmp=%s" % utils.ShellQuote(filename)
281   else:
282     tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
283   cmd = ("%s && "
284          "[[ -f \"${tmp}\" ]] && "
285          "cat > \"${tmp}\" && "
286          "echo \"${tmp}\"") % tmp
287
288   p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
289                        stdin=subprocess.PIPE, stdout=subprocess.PIPE)
290   p.stdin.write(data)
291   p.stdin.close()
292   AssertEqual(p.wait(), 0)
293
294   # Return temporary filename
295   return p.stdout.read().strip()
296
297
298 def BackupFile(node, path):
299   """Creates a backup of a file on the node and returns the filename.
300
301   Caller needs to remove the returned file on the node when it's not needed
302   anymore.
303
304   """
305   cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
306          "[[ -f \"$tmp\" ]] && "
307          "cp %s $tmp && "
308          "echo $tmp") % (utils.ShellQuote(path), utils.ShellQuote(path))
309
310   # Return temporary filename
311   return GetCommandOutput(node, cmd).strip()
312
313
314 def _ResolveName(cmd, key):
315   """Helper function.
316
317   """
318   master = qa_config.GetMasterNode()
319
320   output = GetCommandOutput(master['primary'], utils.ShellQuoteArgs(cmd))
321   for line in output.splitlines():
322     (lkey, lvalue) = line.split(':', 1)
323     if lkey == key:
324       return lvalue.lstrip()
325   raise KeyError("Key not found")
326
327
328 def ResolveInstanceName(instance):
329   """Gets the full name of an instance.
330
331   @type instance: string
332   @param instance: Instance name
333
334   """
335   return _ResolveName(['gnt-instance', 'info', instance],
336                       'Instance name')
337
338
339 def ResolveNodeName(node):
340   """Gets the full name of a node.
341
342   """
343   return _ResolveName(['gnt-node', 'info', node['primary']],
344                       'Node name')
345
346
347 def GetNodeInstances(node, secondaries=False):
348   """Gets a list of instances on a node.
349
350   """
351   master = qa_config.GetMasterNode()
352   node_name = ResolveNodeName(node)
353
354   # Get list of all instances
355   cmd = ['gnt-instance', 'list', '--separator=:', '--no-headers',
356          '--output=name,pnode,snodes']
357   output = GetCommandOutput(master['primary'], utils.ShellQuoteArgs(cmd))
358
359   instances = []
360   for line in output.splitlines():
361     (name, pnode, snodes) = line.split(':', 2)
362     if ((not secondaries and pnode == node_name) or
363         (secondaries and node_name in snodes.split(','))):
364       instances.append(name)
365
366   return instances
367
368
369 def _SelectQueryFields(rnd, fields):
370   """Generates a list of fields for query tests.
371
372   """
373   # Create copy for shuffling
374   fields = list(fields)
375   rnd.shuffle(fields)
376
377   # Check all fields
378   yield fields
379   yield sorted(fields)
380
381   # Duplicate fields
382   yield fields + fields
383
384   # Check small groups of fields
385   while fields:
386     yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
387
388
389 def _List(listcmd, fields, names):
390   """Runs a list command.
391
392   """
393   master = qa_config.GetMasterNode()
394
395   cmd = [listcmd, "list", "--separator=|", "--no-header",
396          "--output", ",".join(fields)]
397
398   if names:
399     cmd.extend(names)
400
401   return GetCommandOutput(master["primary"],
402                           utils.ShellQuoteArgs(cmd)).splitlines()
403
404
405 def GenericQueryTest(cmd, fields):
406   """Runs a number of tests on query commands.
407
408   @param cmd: Command name
409   @param fields: List of field names
410
411   """
412   rnd = random.Random(hash(cmd))
413
414   fields = list(fields)
415   rnd.shuffle(fields)
416
417   # Test a number of field combinations
418   for testfields in _SelectQueryFields(rnd, fields):
419     AssertCommand([cmd, "list", "--output", ",".join(testfields)])
420
421   namelist_fn = compat.partial(_List, cmd, ["name"])
422
423   # When no names were requested, the list must be sorted
424   names = namelist_fn(None)
425   AssertEqual(names, utils.NiceSort(names))
426
427   # When requesting specific names, the order must be kept
428   revnames = list(reversed(names))
429   AssertEqual(namelist_fn(revnames), revnames)
430
431   randnames = list(names)
432   rnd.shuffle(randnames)
433   AssertEqual(namelist_fn(randnames), randnames)
434
435   # Listing unknown items must fail
436   AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"], fail=True)
437
438   # Check exit code for listing unknown field
439   AssertEqual(AssertCommand([cmd, "list", "--output=field/does/not/exist"],
440                             fail=True),
441               constants.EXIT_UNKNOWN_FIELD)
442
443
444 def GenericQueryFieldsTest(cmd, fields):
445   master = qa_config.GetMasterNode()
446
447   # Listing fields
448   AssertCommand([cmd, "list-fields"])
449   AssertCommand([cmd, "list-fields"] + fields)
450
451   # Check listed fields (all, must be sorted)
452   realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
453   output = GetCommandOutput(master["primary"],
454                             utils.ShellQuoteArgs(realcmd)).splitlines()
455   AssertEqual([line.split("|", 1)[0] for line in output],
456               utils.NiceSort(fields))
457
458   # Check exit code for listing unknown field
459   AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
460                             fail=True),
461               constants.EXIT_UNKNOWN_FIELD)
462
463
464 def _FormatWithColor(text, seq):
465   if not seq:
466     return text
467   return "%s%s%s" % (seq, text, _RESET_SEQ)
468
469
470 FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
471 FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
472 FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
473
474
475 def AddToEtcHosts(hostnames):
476   """Adds hostnames to /etc/hosts.
477
478   @param hostnames: List of hostnames first used A records, all other CNAMEs
479
480   """
481   master = qa_config.GetMasterNode()
482   tmp_hosts = UploadData(master["primary"], "", mode=0644)
483
484   quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
485   data = []
486   for localhost in ("::1", "127.0.0.1"):
487     data.append("%s %s" % (localhost, " ".join(hostnames)))
488
489   try:
490     AssertCommand(("cat /etc/hosts > %s && echo -e '%s' >> %s && mv %s"
491                    " /etc/hosts") % (quoted_tmp_hosts, "\\n".join(data),
492                                      quoted_tmp_hosts, quoted_tmp_hosts))
493   except qa_error.Error:
494     AssertCommand(["rm", tmp_hosts])
495
496
497 def RemoveFromEtcHosts(hostnames):
498   """Remove hostnames from /etc/hosts.
499
500   @param hostnames: List of hostnames first used A records, all other CNAMEs
501
502   """
503   master = qa_config.GetMasterNode()
504   tmp_hosts = UploadData(master["primary"], "", mode=0644)
505   quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
506
507   sed_data = " ".join(hostnames)
508   try:
509     AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' /etc/hosts > %s"
510                    " && mv %s /etc/hosts") % (sed_data, quoted_tmp_hosts,
511                                               quoted_tmp_hosts))
512   except qa_error.Error:
513     AssertCommand(["rm", tmp_hosts])