4 # Copyright (C) 2007, 2011, 2012, 2013 Google Inc.
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.
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.
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
28 from ganeti import constants
29 from ganeti import utils
30 from ganeti import serializer
31 from ganeti import compat
37 _INSTANCE_CHECK_KEY = "instance-check"
38 _ENABLED_HV_KEY = "enabled-hypervisors"
39 _VCLUSTER_MASTER_KEY = "vcluster-master"
40 _VCLUSTER_BASEDIR_KEY = "vcluster-basedir"
41 _ENABLED_DISK_TEMPLATES_KEY = "enabled-disk-templates"
43 # The constants related to JSON patching (as per RFC6902) that modifies QA's
45 _QA_BASE_PATH = os.path.dirname(__file__)
46 _QA_DEFAULT_PATCH = "qa-patch.json"
47 _QA_PATCH_DIR = "patch"
49 #: QA configuration (L{_QaConfig})
53 class _QaInstance(object):
61 def __init__(self, name, nicmac):
62 """Initializes instances of this class.
68 self._disk_template = None
71 def FromDict(cls, data):
72 """Creates instance object from JSON dictionary.
77 macaddr = data.get("nic.mac/0")
79 nicmac.append(macaddr)
81 return cls(name=data["name"], nicmac=nicmac)
85 "%s.%s" % (self.__class__.__module__, self.__class__.__name__),
86 "name=%s" % self.name,
87 "nicmac=%s" % self.nicmac,
88 "used=%s" % self._used,
89 "disk_template=%s" % self._disk_template,
92 return "<%s at %#x>" % (" ".join(status), id(self))
95 """Marks instance as being in use.
99 assert self._disk_template is None
104 """Releases instance and makes it available again.
108 ("Instance '%s' was never acquired or released more than once" %
112 self._disk_template = None
114 def GetNicMacAddr(self, idx, default):
115 """Returns MAC address for NIC.
118 @param idx: NIC index
119 @param default: Default value
122 if len(self.nicmac) > idx:
123 return self.nicmac[idx]
127 def SetDiskTemplate(self, template):
128 """Set the disk template.
131 assert template in constants.DISK_TEMPLATES
133 self._disk_template = template
137 """Returns boolean denoting whether instance is in use.
143 def disk_template(self):
144 """Returns the current disk template.
147 return self._disk_template
150 class _QaNode(object):
158 def __init__(self, primary, secondary):
159 """Initializes instances of this class.
162 self.primary = primary
163 self.secondary = secondary
168 def FromDict(cls, data):
169 """Creates node object from JSON dictionary.
172 return cls(primary=data["primary"], secondary=data.get("secondary"))
176 "%s.%s" % (self.__class__.__module__, self.__class__.__name__),
177 "primary=%s" % self.primary,
178 "secondary=%s" % self.secondary,
179 "added=%s" % self._added,
180 "use_count=%s" % self._use_count,
183 return "<%s at %#x>" % (" ".join(status), id(self))
186 """Marks a node as being in use.
189 assert self._use_count >= 0
196 """Release a node (opposite of L{Use}).
199 assert self.use_count > 0
204 """Marks node as having been added to a cluster.
207 assert not self._added
210 def MarkRemoved(self):
211 """Marks node as having been removed from a cluster.
219 """Returns whether a node is part of a cluster.
226 """Returns number of current uses (controlled by L{Use} and L{Release}).
229 return self._use_count
232 _RESOURCE_CONVERTER = {
233 "instances": _QaInstance.FromDict,
234 "nodes": _QaNode.FromDict,
238 def _ConvertResources((key, value)):
239 """Converts cluster resources in configuration to Python objects.
242 fn = _RESOURCE_CONVERTER.get(key, None)
244 return (key, map(fn, value))
249 class _QaConfig(object):
250 def __init__(self, data):
251 """Initializes instances of this class.
256 #: Cluster-wide run-time value of the exclusive storage flag
257 self._exclusive_storage = None
260 def LoadPatch(patch_dict, rel_path):
261 """ Loads a single patch.
263 @type patch_dict: dict of string to dict
264 @param patch_dict: A dictionary storing patches by relative path.
265 @type rel_path: string
266 @param rel_path: The relative path to the patch, might or might not exist.
270 full_path = os.path.join(_QA_BASE_PATH, rel_path)
271 patch = serializer.LoadJson(utils.ReadFile(full_path))
273 patch_dict[rel_path] = patch
279 """ Finds and loads all non-empty patches supported by the QA.
281 @rtype: dict of string to dict
282 @return: A dictionary of relative path to patch content, for non-empty
287 _QaConfig.LoadPatch(patches, _QA_DEFAULT_PATCH)
288 patch_dir_path = os.path.join(_QA_BASE_PATH, _QA_PATCH_DIR)
289 if os.path.exists(patch_dir_path):
290 for filename in os.listdir(patch_dir_path):
291 if filename.endswith(".json"):
292 _QaConfig.LoadPatch(patches, os.path.join(_QA_PATCH_DIR, filename))
296 def ApplyPatches(data, patch_module, patches):
297 """Applies any patches present, and returns the modified QA configuration.
299 First, patches from the patch directory are applied, ordered alphabetically
302 @type data: dict (deserialized json)
303 @param data: The QA configuration
304 @type patch_module: module
305 @param patch_module: The json patch module, loaded dynamically
306 @type patches: dict of string to dict
307 @param patches: The dictionary of patch path to content
309 @return: The modified configuration data.
312 for patch in sorted(patches):
313 if patch != _QA_DEFAULT_PATCH:
314 data = patch_module.apply_patch(data, patches[patch])
316 if _QA_DEFAULT_PATCH in patches:
317 data = patch_module.apply_patch(data, patches[_QA_DEFAULT_PATCH])
322 def Load(cls, filename):
323 """Loads a configuration file and produces a configuration object.
325 @type filename: string
326 @param filename: Path to configuration file
330 data = serializer.LoadJson(utils.ReadFile(filename))
332 # Patch the document using JSON Patch (RFC6902) in file _PATCH_JSON, if
335 patches = _QaConfig.LoadPatches()
337 mod = __import__("jsonpatch", fromlist=[])
338 data = _QaConfig.ApplyPatches(data, mod, patches)
342 raise qa_error.Error("For the QA JSON patching feature to work, you "
343 "need to install Python modules 'jsonpatch' and "
346 result = cls(dict(map(_ConvertResources,
347 data.items()))) # pylint: disable=E1103
353 """Validates loaded configuration data.
356 if not self.get("name"):
357 raise qa_error.Error("Cluster name is required")
359 if not self.get("nodes"):
360 raise qa_error.Error("Need at least one node")
362 if not self.get("instances"):
363 raise qa_error.Error("Need at least one instance")
365 disks = self.GetDiskOptions()
367 raise qa_error.Error("Config option 'disks' must exist")
370 if d.get("size") is None or d.get("growth") is None:
371 raise qa_error.Error("Config options `size` and `growth` must exist"
372 " for all `disks` items")
373 check = self.GetInstanceCheckScript()
377 except EnvironmentError, err:
378 raise qa_error.Error("Can't find instance check script '%s': %s" %
381 enabled_hv = frozenset(self.GetEnabledHypervisors())
383 raise qa_error.Error("No hypervisor is enabled")
385 difference = enabled_hv - constants.HYPER_TYPES
387 raise qa_error.Error("Unknown hypervisor(s) enabled: %s" %
388 utils.CommaJoin(difference))
390 (vc_master, vc_basedir) = self.GetVclusterSettings()
391 if bool(vc_master) != bool(vc_basedir):
392 raise qa_error.Error("All or none of the config options '%s' and '%s'"
394 (_VCLUSTER_MASTER_KEY, _VCLUSTER_BASEDIR_KEY))
396 if vc_basedir and not utils.IsNormAbsPath(vc_basedir):
397 raise qa_error.Error("Path given in option '%s' must be absolute and"
398 " normalized" % _VCLUSTER_BASEDIR_KEY)
400 def __getitem__(self, name):
401 """Returns configuration value.
404 @param name: Name of configuration entry
407 return self._data[name]
409 def get(self, name, default=None):
410 """Returns configuration value.
413 @param name: Name of configuration entry
414 @param default: Default value
417 return self._data.get(name, default)
419 def GetMasterNode(self):
420 """Returns the default master node for the cluster.
423 return self["nodes"][0]
425 def GetInstanceCheckScript(self):
426 """Returns path to instance check script or C{None}.
429 return self._data.get(_INSTANCE_CHECK_KEY, None)
431 def GetEnabledHypervisors(self):
432 """Returns list of enabled hypervisors.
437 return self._GetStringListParameter(
439 [constants.DEFAULT_ENABLED_HYPERVISOR])
441 def GetDefaultHypervisor(self):
442 """Returns the default hypervisor to be used.
445 return self.GetEnabledHypervisors()[0]
447 def GetEnabledDiskTemplates(self):
448 """Returns the list of enabled disk templates.
453 return self._GetStringListParameter(
454 _ENABLED_DISK_TEMPLATES_KEY,
455 list(constants.DEFAULT_ENABLED_DISK_TEMPLATES))
457 def GetDefaultDiskTemplate(self):
458 """Returns the default disk template to be used.
461 return self.GetEnabledDiskTemplates()[0]
463 def _GetStringListParameter(self, key, default_values):
464 """Retrieves a parameter's value that is supposed to be a list of strings.
470 value = self._data[key]
472 return default_values
476 elif isinstance(value, basestring):
477 return value.split(",")
481 def SetExclusiveStorage(self, value):
482 """Set the expected value of the C{exclusive_storage} flag for the cluster.
485 self._exclusive_storage = bool(value)
487 def GetExclusiveStorage(self):
488 """Get the expected value of the C{exclusive_storage} flag for the cluster.
491 value = self._exclusive_storage
492 assert value is not None
495 def IsTemplateSupported(self, templ):
496 """Is the given disk template supported by the current configuration?
499 enabled = templ in self.GetEnabledDiskTemplates()
500 return enabled and (not self.GetExclusiveStorage() or
501 templ in constants.DTS_EXCL_STORAGE)
503 def GetVclusterSettings(self):
504 """Returns settings for virtual cluster.
507 master = self.get(_VCLUSTER_MASTER_KEY)
508 basedir = self.get(_VCLUSTER_BASEDIR_KEY)
510 return (master, basedir)
512 def GetDiskOptions(self):
513 """Return options for the disks of the instances.
515 Get 'disks' parameter from the configuration data. If 'disks' is missing,
516 try to create it from the legacy 'disk' and 'disk-growth' parameters.
520 return self._data["disks"]
525 sizes = self._data.get("disk")
526 growths = self._data.get("disk-growth")
528 if (sizes is None or growths is None or len(sizes) != len(growths)):
529 raise qa_error.Error("Config options 'disk' and 'disk-growth' must"
530 " exist and have the same number of items")
532 for (size, growth) in zip(sizes, growths):
533 disks.append({"size": size, "growth": growth})
540 """Loads the passed configuration file.
543 global _config # pylint: disable=W0603
545 _config = _QaConfig.Load(path)
549 """Returns the configuration object.
553 raise RuntimeError("Configuration not yet loaded")
558 def get(name, default=None):
559 """Wrapper for L{_QaConfig.get}.
562 return GetConfig().get(name, default=default)
566 def __init__(self, tests):
567 """Initializes this class.
569 @type tests: list or string
570 @param tests: List of test names
571 @see: L{TestEnabled} for details
577 def _MakeSequence(value):
578 """Make sequence of single argument.
580 If the single argument is not already a list or tuple, a list with the
581 argument as a single item is returned.
584 if isinstance(value, (list, tuple)):
590 def _TestEnabledInner(check_fn, names, fn):
591 """Evaluate test conditions.
593 @type check_fn: callable
594 @param check_fn: Callback to check whether a test is enabled
595 @type names: sequence or string
596 @param names: Test name(s)
598 @param fn: Aggregation function
600 @return: Whether test is enabled
603 names = _MakeSequence(names)
608 if isinstance(name, Either):
609 value = _TestEnabledInner(check_fn, name.tests, compat.any)
610 elif isinstance(name, (list, tuple)):
611 value = _TestEnabledInner(check_fn, name, compat.all)
615 value = check_fn(name)
622 def TestEnabled(tests, _cfg=None):
623 """Returns True if the given tests are enabled.
625 @param tests: A single test as a string, or a list of tests to check; can
626 contain L{Either} for OR conditions, AND is default
634 # Get settings for all tests
635 cfg_tests = cfg.get("tests", {})
637 # Get default setting
638 default = cfg_tests.get("default", True)
640 return _TestEnabledInner(lambda name: cfg_tests.get(name, default),
644 def GetInstanceCheckScript(*args):
645 """Wrapper for L{_QaConfig.GetInstanceCheckScript}.
648 return GetConfig().GetInstanceCheckScript(*args)
651 def GetEnabledHypervisors(*args):
652 """Wrapper for L{_QaConfig.GetEnabledHypervisors}.
655 return GetConfig().GetEnabledHypervisors(*args)
658 def GetDefaultHypervisor(*args):
659 """Wrapper for L{_QaConfig.GetDefaultHypervisor}.
662 return GetConfig().GetDefaultHypervisor(*args)
665 def GetEnabledDiskTemplates(*args):
666 """Wrapper for L{_QaConfig.GetEnabledDiskTemplates}.
669 return GetConfig().GetEnabledDiskTemplates(*args)
672 def GetDefaultDiskTemplate(*args):
673 """Wrapper for L{_QaConfig.GetDefaultDiskTemplate}.
676 return GetConfig().GetDefaultDiskTemplate(*args)
680 """Wrapper for L{_QaConfig.GetMasterNode}.
683 return GetConfig().GetMasterNode()
686 def AcquireInstance(_cfg=None):
687 """Returns an instance which isn't in use.
695 # Filter out unwanted instances
696 instances = filter(lambda inst: not inst.used, cfg["instances"])
699 raise qa_error.OutOfInstancesError("No instances left")
701 instance = instances[0]
707 def SetExclusiveStorage(value):
708 """Wrapper for L{_QaConfig.SetExclusiveStorage}.
711 return GetConfig().SetExclusiveStorage(value)
714 def GetExclusiveStorage():
715 """Wrapper for L{_QaConfig.GetExclusiveStorage}.
718 return GetConfig().GetExclusiveStorage()
721 def IsTemplateSupported(templ):
722 """Wrapper for L{_QaConfig.IsTemplateSupported}.
725 return GetConfig().IsTemplateSupported(templ)
728 def _NodeSortKey(node):
729 """Returns sort key for a node.
731 @type node: L{_QaNode}
734 return (node.use_count, utils.NiceSortKey(node.primary))
737 def AcquireNode(exclude=None, _cfg=None):
738 """Returns the least used node.
746 master = cfg.GetMasterNode()
748 # Filter out unwanted nodes
749 # TODO: Maybe combine filters
751 nodes = cfg["nodes"][:]
752 elif isinstance(exclude, (list, tuple)):
753 nodes = filter(lambda node: node not in exclude, cfg["nodes"])
755 nodes = filter(lambda node: node != exclude, cfg["nodes"])
757 nodes = filter(lambda node: node.added or node == master, nodes)
760 raise qa_error.OutOfNodesError("No nodes left")
762 # Return node with least number of uses
763 return sorted(nodes, key=_NodeSortKey)[0].Use()
766 def AcquireManyNodes(num, exclude=None):
767 """Return the least used nodes.
770 @param num: Number of nodes; can be 0.
771 @type exclude: list of nodes or C{None}
772 @param exclude: nodes to be excluded from the choice
773 @rtype: list of nodes
774 @return: C{num} different nodes
780 elif isinstance(exclude, (list, tuple)):
781 # Don't modify the incoming argument
782 exclude = list(exclude)
787 for _ in range(0, num):
788 n = AcquireNode(exclude=exclude)
791 except qa_error.OutOfNodesError:
792 ReleaseManyNodes(nodes)
797 def ReleaseManyNodes(nodes):
802 def GetVclusterSettings():
803 """Wrapper for L{_QaConfig.GetVclusterSettings}.
806 return GetConfig().GetVclusterSettings()
809 def UseVirtualCluster(_cfg=None):
810 """Returns whether a virtual cluster is used.
820 (master, _) = cfg.GetVclusterSettings()
825 @ht.WithDesc("No virtual cluster")
826 def NoVirtualCluster():
827 """Used to disable tests for virtual clusters.
830 return not UseVirtualCluster()
833 def GetDiskOptions():
834 """Wrapper for L{_QaConfig.GetDiskOptions}.
837 return GetConfig().GetDiskOptions()