e8b49bdb60a26e40d973e2ccf2d6478ea379b0fd
[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 operator
27 import os
28 import random
29 import re
30 import subprocess
31 import sys
32 import tempfile
33 import yaml
34
35 try:
36   import functools
37 except ImportError, err:
38   raise ImportError("Python 2.5 or higher is required: %s" % err)
39
40 from ganeti import utils
41 from ganeti import compat
42 from ganeti import constants
43 from ganeti import ht
44 from ganeti import pathutils
45 from ganeti import vcluster
46
47 import qa_config
48 import qa_error
49
50
51 _INFO_SEQ = None
52 _WARNING_SEQ = None
53 _ERROR_SEQ = None
54 _RESET_SEQ = None
55
56 _MULTIPLEXERS = {}
57
58 #: Unique ID per QA run
59 _RUN_UUID = utils.NewUUID()
60
61 #: Path to the QA query output log file
62 _QA_OUTPUT = pathutils.GetLogFilename("qa-output")
63
64
65 (INST_DOWN,
66  INST_UP) = range(500, 502)
67
68 (FIRST_ARG,
69  RETURN_VALUE) = range(1000, 1002)
70
71
72 def _SetupColours():
73   """Initializes the colour constants.
74
75   """
76   # pylint: disable=W0603
77   # due to global usage
78   global _INFO_SEQ, _WARNING_SEQ, _ERROR_SEQ, _RESET_SEQ
79
80   # Don't use colours if stdout isn't a terminal
81   if not sys.stdout.isatty():
82     return
83
84   try:
85     import curses
86   except ImportError:
87     # Don't use colours if curses module can't be imported
88     return
89
90   curses.setupterm()
91
92   _RESET_SEQ = curses.tigetstr("op")
93
94   setaf = curses.tigetstr("setaf")
95   _INFO_SEQ = curses.tparm(setaf, curses.COLOR_GREEN)
96   _WARNING_SEQ = curses.tparm(setaf, curses.COLOR_YELLOW)
97   _ERROR_SEQ = curses.tparm(setaf, curses.COLOR_RED)
98
99
100 _SetupColours()
101
102
103 def AssertIn(item, sequence):
104   """Raises an error when item is not in sequence.
105
106   """
107   if item not in sequence:
108     raise qa_error.Error("%r not in %r" % (item, sequence))
109
110
111 def AssertNotIn(item, sequence):
112   """Raises an error when item is in sequence.
113
114   """
115   if item in sequence:
116     raise qa_error.Error("%r in %r" % (item, sequence))
117
118
119 def AssertEqual(first, second):
120   """Raises an error when values aren't equal.
121
122   """
123   if not first == second:
124     raise qa_error.Error("%r == %r" % (first, second))
125
126
127 def AssertMatch(string, pattern):
128   """Raises an error when string doesn't match regexp pattern.
129
130   """
131   if not re.match(pattern, string):
132     raise qa_error.Error("%r doesn't match /%r/" % (string, pattern))
133
134
135 def _GetName(entity, fn):
136   """Tries to get name of an entity.
137
138   @type entity: string or dict
139   @param fn: Function retrieving name from entity
140
141   """
142   if isinstance(entity, basestring):
143     result = entity
144   else:
145     result = fn(entity)
146
147   if not ht.TNonEmptyString(result):
148     raise Exception("Invalid name '%s'" % result)
149
150   return result
151
152
153 def _AssertRetCode(rcode, fail, cmdstr, nodename):
154   """Check the return value from a command and possibly raise an exception.
155
156   """
157   if fail and rcode == 0:
158     raise qa_error.Error("Command '%s' on node %s was expected to fail but"
159                          " didn't" % (cmdstr, nodename))
160   elif not fail and rcode != 0:
161     raise qa_error.Error("Command '%s' on node %s failed, exit code %s" %
162                          (cmdstr, nodename, rcode))
163
164
165 def AssertCommand(cmd, fail=False, node=None, log_cmd=True):
166   """Checks that a remote command succeeds.
167
168   @param cmd: either a string (the command to execute) or a list (to
169       be converted using L{utils.ShellQuoteArgs} into a string)
170   @type fail: boolean
171   @param fail: if the command is expected to fail instead of succeeding
172   @param node: if passed, it should be the node on which the command
173       should be executed, instead of the master node (can be either a
174       dict or a string)
175   @param log_cmd: if False, the command won't be logged (simply passed to
176       StartSSH)
177   @return: the return code of the command
178   @raise qa_error.Error: if the command fails when it shouldn't or vice versa
179
180   """
181   if node is None:
182     node = qa_config.GetMasterNode()
183
184   nodename = _GetName(node, operator.attrgetter("primary"))
185
186   if isinstance(cmd, basestring):
187     cmdstr = cmd
188   else:
189     cmdstr = utils.ShellQuoteArgs(cmd)
190
191   rcode = StartSSH(nodename, cmdstr, log_cmd=log_cmd).wait()
192   _AssertRetCode(rcode, fail, cmdstr, nodename)
193
194   return rcode
195
196
197 def AssertRedirectedCommand(cmd, fail=False, node=None, log_cmd=True):
198   """Executes a command with redirected output.
199
200   The log will go to the qa-output log file in the ganeti log
201   directory on the node where the command is executed. The fail and
202   node parameters are passed unchanged to AssertCommand.
203
204   @param cmd: the command to be executed, as a list; a string is not
205       supported
206
207   """
208   if not isinstance(cmd, list):
209     raise qa_error.Error("Non-list passed to AssertRedirectedCommand")
210   ofile = utils.ShellQuote(_QA_OUTPUT)
211   cmdstr = utils.ShellQuoteArgs(cmd)
212   AssertCommand("echo ---- $(date) %s ---- >> %s" % (cmdstr, ofile),
213                 fail=False, node=node, log_cmd=False)
214   return AssertCommand(cmdstr + " >> %s" % ofile,
215                        fail=fail, node=node, log_cmd=log_cmd)
216
217
218 def GetSSHCommand(node, cmd, strict=True, opts=None, tty=None):
219   """Builds SSH command to be executed.
220
221   @type node: string
222   @param node: node the command should run on
223   @type cmd: string
224   @param cmd: command to be executed in the node; if None or empty
225       string, no command will be executed
226   @type strict: boolean
227   @param strict: whether to enable strict host key checking
228   @type opts: list
229   @param opts: list of additional options
230   @type tty: boolean or None
231   @param tty: if we should use tty; if None, will be auto-detected
232
233   """
234   args = ["ssh", "-oEscapeChar=none", "-oBatchMode=yes", "-lroot"]
235
236   if tty is None:
237     tty = sys.stdout.isatty()
238
239   if tty:
240     args.append("-t")
241
242   if strict:
243     tmp = "yes"
244   else:
245     tmp = "no"
246   args.append("-oStrictHostKeyChecking=%s" % tmp)
247   args.append("-oClearAllForwardings=yes")
248   args.append("-oForwardAgent=yes")
249   if opts:
250     args.extend(opts)
251   if node in _MULTIPLEXERS:
252     spath = _MULTIPLEXERS[node][0]
253     args.append("-oControlPath=%s" % spath)
254     args.append("-oControlMaster=no")
255
256   (vcluster_master, vcluster_basedir) = \
257     qa_config.GetVclusterSettings()
258
259   if vcluster_master:
260     args.append(vcluster_master)
261     args.append("%s/%s/cmd" % (vcluster_basedir, node))
262
263     if cmd:
264       # For virtual clusters the whole command must be wrapped using the "cmd"
265       # script, as that script sets a number of environment variables. If the
266       # command contains shell meta characters the whole command needs to be
267       # quoted.
268       args.append(utils.ShellQuote(cmd))
269   else:
270     args.append(node)
271
272     if cmd:
273       args.append(cmd)
274
275   return args
276
277
278 def StartLocalCommand(cmd, _nolog_opts=False, log_cmd=True, **kwargs):
279   """Starts a local command.
280
281   """
282   if log_cmd:
283     if _nolog_opts:
284       pcmd = [i for i in cmd if not i.startswith("-")]
285     else:
286       pcmd = cmd
287     print "Command: %s" % utils.ShellQuoteArgs(pcmd)
288   return subprocess.Popen(cmd, shell=False, **kwargs)
289
290
291 def StartSSH(node, cmd, strict=True, log_cmd=True):
292   """Starts SSH.
293
294   """
295   return StartLocalCommand(GetSSHCommand(node, cmd, strict=strict),
296                            _nolog_opts=True, log_cmd=log_cmd)
297
298
299 def StartMultiplexer(node):
300   """Starts a multiplexer command.
301
302   @param node: the node for which to open the multiplexer
303
304   """
305   if node in _MULTIPLEXERS:
306     return
307
308   # Note: yes, we only need mktemp, since we'll remove the file anyway
309   sname = tempfile.mktemp(prefix="ganeti-qa-multiplexer.")
310   utils.RemoveFile(sname)
311   opts = ["-N", "-oControlPath=%s" % sname, "-oControlMaster=yes"]
312   print "Created socket at %s" % sname
313   child = StartLocalCommand(GetSSHCommand(node, None, opts=opts))
314   _MULTIPLEXERS[node] = (sname, child)
315
316
317 def CloseMultiplexers():
318   """Closes all current multiplexers and cleans up.
319
320   """
321   for node in _MULTIPLEXERS.keys():
322     (sname, child) = _MULTIPLEXERS.pop(node)
323     utils.KillProcess(child.pid, timeout=10, waitpid=True)
324     utils.RemoveFile(sname)
325
326
327 def GetCommandOutput(node, cmd, tty=None, fail=False):
328   """Returns the output of a command executed on the given node.
329
330   @type node: string
331   @param node: node the command should run on
332   @type cmd: string
333   @param cmd: command to be executed in the node (cannot be empty or None)
334   @type tty: bool or None
335   @param tty: if we should use tty; if None, it will be auto-detected
336   @type fail: bool
337   @param fail: whether the command is expected to fail
338   """
339   assert cmd
340   p = StartLocalCommand(GetSSHCommand(node, cmd, tty=tty),
341                         stdout=subprocess.PIPE)
342   rcode = p.wait()
343   _AssertRetCode(rcode, fail, cmd, node)
344   return p.stdout.read()
345
346
347 def GetObjectInfo(infocmd):
348   """Get and parse information about a Ganeti object.
349
350   @type infocmd: list of strings
351   @param infocmd: command to be executed, e.g. ["gnt-cluster", "info"]
352   @return: the information parsed, appropriately stored in dictionaries,
353       lists...
354
355   """
356   master = qa_config.GetMasterNode()
357   cmdline = utils.ShellQuoteArgs(infocmd)
358   info_out = GetCommandOutput(master.primary, cmdline)
359   return yaml.load(info_out)
360
361
362 def UploadFile(node, src):
363   """Uploads a file to a node and returns the filename.
364
365   Caller needs to remove the returned file on the node when it's not needed
366   anymore.
367
368   """
369   # Make sure nobody else has access to it while preserving local permissions
370   mode = os.stat(src).st_mode & 0700
371
372   cmd = ('tmp=$(tempfile --mode %o --prefix gnt) && '
373          '[[ -f "${tmp}" ]] && '
374          'cat > "${tmp}" && '
375          'echo "${tmp}"') % mode
376
377   f = open(src, "r")
378   try:
379     p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False, stdin=f,
380                          stdout=subprocess.PIPE)
381     AssertEqual(p.wait(), 0)
382
383     # Return temporary filename
384     return p.stdout.read().strip()
385   finally:
386     f.close()
387
388
389 def UploadData(node, data, mode=0600, filename=None):
390   """Uploads data to a node and returns the filename.
391
392   Caller needs to remove the returned file on the node when it's not needed
393   anymore.
394
395   """
396   if filename:
397     tmp = "tmp=%s" % utils.ShellQuote(filename)
398   else:
399     tmp = "tmp=$(tempfile --mode %o --prefix gnt)" % mode
400   cmd = ("%s && "
401          "[[ -f \"${tmp}\" ]] && "
402          "cat > \"${tmp}\" && "
403          "echo \"${tmp}\"") % tmp
404
405   p = subprocess.Popen(GetSSHCommand(node, cmd), shell=False,
406                        stdin=subprocess.PIPE, stdout=subprocess.PIPE)
407   p.stdin.write(data)
408   p.stdin.close()
409   AssertEqual(p.wait(), 0)
410
411   # Return temporary filename
412   return p.stdout.read().strip()
413
414
415 def BackupFile(node, path):
416   """Creates a backup of a file on the node and returns the filename.
417
418   Caller needs to remove the returned file on the node when it's not needed
419   anymore.
420
421   """
422   vpath = MakeNodePath(node, path)
423
424   cmd = ("tmp=$(tempfile --prefix .gnt --directory=$(dirname %s)) && "
425          "[[ -f \"$tmp\" ]] && "
426          "cp %s $tmp && "
427          "echo $tmp") % (utils.ShellQuote(vpath), utils.ShellQuote(vpath))
428
429   # Return temporary filename
430   result = GetCommandOutput(node, cmd).strip()
431
432   print "Backup filename: %s" % result
433
434   return result
435
436
437 def ResolveInstanceName(instance):
438   """Gets the full name of an instance.
439
440   @type instance: string
441   @param instance: Instance name
442
443   """
444   info = GetObjectInfo(["gnt-instance", "info", instance])
445   return info[0]["Instance name"]
446
447
448 def ResolveNodeName(node):
449   """Gets the full name of a node.
450
451   """
452   info = GetObjectInfo(["gnt-node", "info", node.primary])
453   return info[0]["Node name"]
454
455
456 def GetNodeInstances(node, secondaries=False):
457   """Gets a list of instances on a node.
458
459   """
460   master = qa_config.GetMasterNode()
461   node_name = ResolveNodeName(node)
462
463   # Get list of all instances
464   cmd = ["gnt-instance", "list", "--separator=:", "--no-headers",
465          "--output=name,pnode,snodes"]
466   output = GetCommandOutput(master.primary, utils.ShellQuoteArgs(cmd))
467
468   instances = []
469   for line in output.splitlines():
470     (name, pnode, snodes) = line.split(":", 2)
471     if ((not secondaries and pnode == node_name) or
472         (secondaries and node_name in snodes.split(","))):
473       instances.append(name)
474
475   return instances
476
477
478 def _SelectQueryFields(rnd, fields):
479   """Generates a list of fields for query tests.
480
481   """
482   # Create copy for shuffling
483   fields = list(fields)
484   rnd.shuffle(fields)
485
486   # Check all fields
487   yield fields
488   yield sorted(fields)
489
490   # Duplicate fields
491   yield fields + fields
492
493   # Check small groups of fields
494   while fields:
495     yield [fields.pop() for _ in range(rnd.randint(2, 10)) if fields]
496
497
498 def _List(listcmd, fields, names):
499   """Runs a list command.
500
501   """
502   master = qa_config.GetMasterNode()
503
504   cmd = [listcmd, "list", "--separator=|", "--no-headers",
505          "--output", ",".join(fields)]
506
507   if names:
508     cmd.extend(names)
509
510   return GetCommandOutput(master.primary,
511                           utils.ShellQuoteArgs(cmd)).splitlines()
512
513
514 def GenericQueryTest(cmd, fields, namefield="name", test_unknown=True):
515   """Runs a number of tests on query commands.
516
517   @param cmd: Command name
518   @param fields: List of field names
519
520   """
521   rnd = random.Random(hash(cmd))
522
523   fields = list(fields)
524   rnd.shuffle(fields)
525
526   # Test a number of field combinations
527   for testfields in _SelectQueryFields(rnd, fields):
528     AssertRedirectedCommand([cmd, "list", "--output", ",".join(testfields)])
529
530   if namefield is not None:
531     namelist_fn = compat.partial(_List, cmd, [namefield])
532
533     # When no names were requested, the list must be sorted
534     names = namelist_fn(None)
535     AssertEqual(names, utils.NiceSort(names))
536
537     # When requesting specific names, the order must be kept
538     revnames = list(reversed(names))
539     AssertEqual(namelist_fn(revnames), revnames)
540
541     randnames = list(names)
542     rnd.shuffle(randnames)
543     AssertEqual(namelist_fn(randnames), randnames)
544
545   if test_unknown:
546     # Listing unknown items must fail
547     AssertCommand([cmd, "list", "this.name.certainly.does.not.exist"],
548                   fail=True)
549
550   # Check exit code for listing unknown field
551   AssertEqual(AssertRedirectedCommand([cmd, "list",
552                                        "--output=field/does/not/exist"],
553                                       fail=True),
554               constants.EXIT_UNKNOWN_FIELD)
555
556
557 def GenericQueryFieldsTest(cmd, fields):
558   master = qa_config.GetMasterNode()
559
560   # Listing fields
561   AssertRedirectedCommand([cmd, "list-fields"])
562   AssertRedirectedCommand([cmd, "list-fields"] + fields)
563
564   # Check listed fields (all, must be sorted)
565   realcmd = [cmd, "list-fields", "--separator=|", "--no-headers"]
566   output = GetCommandOutput(master.primary,
567                             utils.ShellQuoteArgs(realcmd)).splitlines()
568   AssertEqual([line.split("|", 1)[0] for line in output],
569               utils.NiceSort(fields))
570
571   # Check exit code for listing unknown field
572   AssertEqual(AssertCommand([cmd, "list-fields", "field/does/not/exist"],
573                             fail=True),
574               constants.EXIT_UNKNOWN_FIELD)
575
576
577 def _FormatWithColor(text, seq):
578   if not seq:
579     return text
580   return "%s%s%s" % (seq, text, _RESET_SEQ)
581
582
583 FormatWarning = lambda text: _FormatWithColor(text, _WARNING_SEQ)
584 FormatError = lambda text: _FormatWithColor(text, _ERROR_SEQ)
585 FormatInfo = lambda text: _FormatWithColor(text, _INFO_SEQ)
586
587
588 def AddToEtcHosts(hostnames):
589   """Adds hostnames to /etc/hosts.
590
591   @param hostnames: List of hostnames first used A records, all other CNAMEs
592
593   """
594   master = qa_config.GetMasterNode()
595   tmp_hosts = UploadData(master.primary, "", mode=0644)
596
597   data = []
598   for localhost in ("::1", "127.0.0.1"):
599     data.append("%s %s" % (localhost, " ".join(hostnames)))
600
601   try:
602     AssertCommand("{ cat %s && echo -e '%s'; } > %s && mv %s %s" %
603                   (utils.ShellQuote(pathutils.ETC_HOSTS),
604                    "\\n".join(data),
605                    utils.ShellQuote(tmp_hosts),
606                    utils.ShellQuote(tmp_hosts),
607                    utils.ShellQuote(pathutils.ETC_HOSTS)))
608   except Exception:
609     AssertCommand(["rm", "-f", tmp_hosts])
610     raise
611
612
613 def RemoveFromEtcHosts(hostnames):
614   """Remove hostnames from /etc/hosts.
615
616   @param hostnames: List of hostnames first used A records, all other CNAMEs
617
618   """
619   master = qa_config.GetMasterNode()
620   tmp_hosts = UploadData(master.primary, "", mode=0644)
621   quoted_tmp_hosts = utils.ShellQuote(tmp_hosts)
622
623   sed_data = " ".join(hostnames)
624   try:
625     AssertCommand(("sed -e '/^\(::1\|127\.0\.0\.1\)\s\+%s/d' %s > %s"
626                    " && mv %s %s") %
627                    (sed_data, utils.ShellQuote(pathutils.ETC_HOSTS),
628                     quoted_tmp_hosts, quoted_tmp_hosts,
629                     utils.ShellQuote(pathutils.ETC_HOSTS)))
630   except Exception:
631     AssertCommand(["rm", "-f", tmp_hosts])
632     raise
633
634
635 def RunInstanceCheck(instance, running):
636   """Check if instance is running or not.
637
638   """
639   instance_name = _GetName(instance, operator.attrgetter("name"))
640
641   script = qa_config.GetInstanceCheckScript()
642   if not script:
643     return
644
645   master_node = qa_config.GetMasterNode()
646
647   # Build command to connect to master node
648   master_ssh = GetSSHCommand(master_node.primary, "--")
649
650   if running:
651     running_shellval = "1"
652     running_text = ""
653   else:
654     running_shellval = ""
655     running_text = "not "
656
657   print FormatInfo("Checking if instance '%s' is %srunning" %
658                    (instance_name, running_text))
659
660   args = [script, instance_name]
661   env = {
662     "PATH": constants.HOOKS_PATH,
663     "RUN_UUID": _RUN_UUID,
664     "MASTER_SSH": utils.ShellQuoteArgs(master_ssh),
665     "INSTANCE_NAME": instance_name,
666     "INSTANCE_RUNNING": running_shellval,
667     }
668
669   result = os.spawnve(os.P_WAIT, script, args, env)
670   if result != 0:
671     raise qa_error.Error("Instance check failed with result %s" % result)
672
673
674 def _InstanceCheckInner(expected, instarg, args, result):
675   """Helper function used by L{InstanceCheck}.
676
677   """
678   if instarg == FIRST_ARG:
679     instance = args[0]
680   elif instarg == RETURN_VALUE:
681     instance = result
682   else:
683     raise Exception("Invalid value '%s' for instance argument" % instarg)
684
685   if expected in (INST_DOWN, INST_UP):
686     RunInstanceCheck(instance, (expected == INST_UP))
687   elif expected is not None:
688     raise Exception("Invalid value '%s'" % expected)
689
690
691 def InstanceCheck(before, after, instarg):
692   """Decorator to check instance status before and after test.
693
694   @param before: L{INST_DOWN} if instance must be stopped before test,
695     L{INST_UP} if instance must be running before test, L{None} to not check.
696   @param after: L{INST_DOWN} if instance must be stopped after test,
697     L{INST_UP} if instance must be running after test, L{None} to not check.
698   @param instarg: L{FIRST_ARG} to use first argument to test as instance (a
699     dictionary), L{RETURN_VALUE} to use return value (disallows pre-checks)
700
701   """
702   def decorator(fn):
703     @functools.wraps(fn)
704     def wrapper(*args, **kwargs):
705       _InstanceCheckInner(before, instarg, args, NotImplemented)
706
707       result = fn(*args, **kwargs)
708
709       _InstanceCheckInner(after, instarg, args, result)
710
711       return result
712     return wrapper
713   return decorator
714
715
716 def GetNonexistentGroups(count):
717   """Gets group names which shouldn't exist on the cluster.
718
719   @param count: Number of groups to get
720   @rtype: integer
721
722   """
723   return GetNonexistentEntityNames(count, "groups", "group")
724
725
726 def GetNonexistentEntityNames(count, name_config, name_prefix):
727   """Gets entity names which shouldn't exist on the cluster.
728
729   The actualy names can refer to arbitrary entities (for example
730   groups, networks).
731
732   @param count: Number of names to get
733   @rtype: integer
734   @param name_config: name of the leaf in the config containing
735     this entity's configuration, including a 'inexistent-'
736     element
737   @rtype: string
738   @param name_prefix: prefix of the entity's names, used to compose
739     the default values; for example for groups, the prefix is
740     'group' and the generated names are then group1, group2, ...
741   @rtype: string
742
743   """
744   entities = qa_config.get(name_config, {})
745
746   default = [name_prefix + str(i) for i in range(count)]
747   assert count <= len(default)
748
749   name_config_inexistent = "inexistent-" + name_config
750   candidates = entities.get(name_config_inexistent, default)[:count]
751
752   if len(candidates) < count:
753     raise Exception("At least %s non-existent %s are needed" %
754                     (count, name_config))
755
756   return candidates
757
758
759 def MakeNodePath(node, path):
760   """Builds an absolute path for a virtual node.
761
762   @type node: string or L{qa_config._QaNode}
763   @param node: Node
764   @type path: string
765   @param path: Path without node-specific prefix
766
767   """
768   (_, basedir) = qa_config.GetVclusterSettings()
769
770   if isinstance(node, basestring):
771     name = node
772   else:
773     name = node.primary
774
775   if basedir:
776     assert path.startswith("/")
777     return "%s%s" % (vcluster.MakeNodeRoot(basedir, name), path)
778   else:
779     return path