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