Allow multiple QA patches
[ganeti-local] / qa / qa_config.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 """QA configuration.
23
24 """
25
26 import os
27
28 from ganeti import constants
29 from ganeti import utils
30 from ganeti import serializer
31 from ganeti import compat
32 from ganeti import ht
33
34 import qa_error
35
36
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"
42
43 # The constants related to JSON patching (as per RFC6902) that modifies QA's
44 # configuration.
45 _QA_BASE_PATH = os.path.dirname(__file__)
46 _QA_DEFAULT_PATCH = "qa-patch.json"
47 _QA_PATCH_DIR = "patch"
48
49 #: QA configuration (L{_QaConfig})
50 _config = None
51
52
53 class _QaInstance(object):
54   __slots__ = [
55     "name",
56     "nicmac",
57     "_used",
58     "_disk_template",
59     ]
60
61   def __init__(self, name, nicmac):
62     """Initializes instances of this class.
63
64     """
65     self.name = name
66     self.nicmac = nicmac
67     self._used = None
68     self._disk_template = None
69
70   @classmethod
71   def FromDict(cls, data):
72     """Creates instance object from JSON dictionary.
73
74     """
75     nicmac = []
76
77     macaddr = data.get("nic.mac/0")
78     if macaddr:
79       nicmac.append(macaddr)
80
81     return cls(name=data["name"], nicmac=nicmac)
82
83   def __repr__(self):
84     status = [
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,
90       ]
91
92     return "<%s at %#x>" % (" ".join(status), id(self))
93
94   def Use(self):
95     """Marks instance as being in use.
96
97     """
98     assert not self._used
99     assert self._disk_template is None
100
101     self._used = True
102
103   def Release(self):
104     """Releases instance and makes it available again.
105
106     """
107     assert self._used, \
108       ("Instance '%s' was never acquired or released more than once" %
109        self.name)
110
111     self._used = False
112     self._disk_template = None
113
114   def GetNicMacAddr(self, idx, default):
115     """Returns MAC address for NIC.
116
117     @type idx: int
118     @param idx: NIC index
119     @param default: Default value
120
121     """
122     if len(self.nicmac) > idx:
123       return self.nicmac[idx]
124     else:
125       return default
126
127   def SetDiskTemplate(self, template):
128     """Set the disk template.
129
130     """
131     assert template in constants.DISK_TEMPLATES
132
133     self._disk_template = template
134
135   @property
136   def used(self):
137     """Returns boolean denoting whether instance is in use.
138
139     """
140     return self._used
141
142   @property
143   def disk_template(self):
144     """Returns the current disk template.
145
146     """
147     return self._disk_template
148
149
150 class _QaNode(object):
151   __slots__ = [
152     "primary",
153     "secondary",
154     "_added",
155     "_use_count",
156     ]
157
158   def __init__(self, primary, secondary):
159     """Initializes instances of this class.
160
161     """
162     self.primary = primary
163     self.secondary = secondary
164     self._added = False
165     self._use_count = 0
166
167   @classmethod
168   def FromDict(cls, data):
169     """Creates node object from JSON dictionary.
170
171     """
172     return cls(primary=data["primary"], secondary=data.get("secondary"))
173
174   def __repr__(self):
175     status = [
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,
181       ]
182
183     return "<%s at %#x>" % (" ".join(status), id(self))
184
185   def Use(self):
186     """Marks a node as being in use.
187
188     """
189     assert self._use_count >= 0
190
191     self._use_count += 1
192
193     return self
194
195   def Release(self):
196     """Release a node (opposite of L{Use}).
197
198     """
199     assert self.use_count > 0
200
201     self._use_count -= 1
202
203   def MarkAdded(self):
204     """Marks node as having been added to a cluster.
205
206     """
207     assert not self._added
208     self._added = True
209
210   def MarkRemoved(self):
211     """Marks node as having been removed from a cluster.
212
213     """
214     assert self._added
215     self._added = False
216
217   @property
218   def added(self):
219     """Returns whether a node is part of a cluster.
220
221     """
222     return self._added
223
224   @property
225   def use_count(self):
226     """Returns number of current uses (controlled by L{Use} and L{Release}).
227
228     """
229     return self._use_count
230
231
232 _RESOURCE_CONVERTER = {
233   "instances": _QaInstance.FromDict,
234   "nodes": _QaNode.FromDict,
235   }
236
237
238 def _ConvertResources((key, value)):
239   """Converts cluster resources in configuration to Python objects.
240
241   """
242   fn = _RESOURCE_CONVERTER.get(key, None)
243   if fn:
244     return (key, map(fn, value))
245   else:
246     return (key, value)
247
248
249 class _QaConfig(object):
250   def __init__(self, data):
251     """Initializes instances of this class.
252
253     """
254     self._data = data
255
256     #: Cluster-wide run-time value of the exclusive storage flag
257     self._exclusive_storage = None
258
259   @staticmethod
260   def LoadPatch(patch_dict, rel_path):
261     """ Loads a single patch.
262
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.
267
268     """
269     try:
270       full_path = os.path.join(_QA_BASE_PATH, rel_path)
271       patch = serializer.LoadJson(utils.ReadFile(full_path))
272       if patch:
273         patch_dict[rel_path] = patch
274     except IOError:
275       pass
276
277   @staticmethod
278   def LoadPatches():
279     """ Finds and loads all non-empty patches supported by the QA.
280
281     @rtype: dict of string to dict
282     @return: A dictionary of relative path to patch content, for non-empty
283              patches.
284
285     """
286     patches = {}
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))
293     return patches
294
295   @staticmethod
296   def ApplyPatches(data, patch_module, patches):
297     """Applies any patches present, and returns the modified QA configuration.
298
299     First, patches from the patch directory are applied, ordered alphabetically
300     by name.
301
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
308
309     @return: The modified configuration data.
310
311     """
312     for patch in sorted(patches):
313       if patch != _QA_DEFAULT_PATCH:
314         data = patch_module.apply_patch(data, patches[patch])
315
316     if _QA_DEFAULT_PATCH in patches:
317       data = patch_module.apply_patch(data, patches[_QA_DEFAULT_PATCH])
318
319     return data
320
321   @classmethod
322   def Load(cls, filename):
323     """Loads a configuration file and produces a configuration object.
324
325     @type filename: string
326     @param filename: Path to configuration file
327     @rtype: L{_QaConfig}
328
329     """
330     data = serializer.LoadJson(utils.ReadFile(filename))
331
332     # Patch the document using JSON Patch (RFC6902) in file _PATCH_JSON, if
333     # available
334     try:
335       patches = _QaConfig.LoadPatches()
336       if patches:
337         mod = __import__("jsonpatch", fromlist=[])
338         data = _QaConfig.ApplyPatches(data, mod, patches)
339     except IOError:
340       pass
341     except ImportError:
342       raise qa_error.Error("For the QA JSON patching feature to work, you "
343                            "need to install Python modules 'jsonpatch' and "
344                            "'jsonpointer'.")
345
346     result = cls(dict(map(_ConvertResources,
347                           data.items()))) # pylint: disable=E1103
348     result.Validate()
349
350     return result
351
352   def Validate(self):
353     """Validates loaded configuration data.
354
355     """
356     if not self.get("name"):
357       raise qa_error.Error("Cluster name is required")
358
359     if not self.get("nodes"):
360       raise qa_error.Error("Need at least one node")
361
362     if not self.get("instances"):
363       raise qa_error.Error("Need at least one instance")
364
365     disks = self.GetDiskOptions()
366     if disks is None:
367       raise qa_error.Error("Config option 'disks' must exist")
368     else:
369       for d in disks:
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()
374     if check:
375       try:
376         os.stat(check)
377       except EnvironmentError, err:
378         raise qa_error.Error("Can't find instance check script '%s': %s" %
379                              (check, err))
380
381     enabled_hv = frozenset(self.GetEnabledHypervisors())
382     if not enabled_hv:
383       raise qa_error.Error("No hypervisor is enabled")
384
385     difference = enabled_hv - constants.HYPER_TYPES
386     if difference:
387       raise qa_error.Error("Unknown hypervisor(s) enabled: %s" %
388                            utils.CommaJoin(difference))
389
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'"
393                            " must be set" %
394                            (_VCLUSTER_MASTER_KEY, _VCLUSTER_BASEDIR_KEY))
395
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)
399
400   def __getitem__(self, name):
401     """Returns configuration value.
402
403     @type name: string
404     @param name: Name of configuration entry
405
406     """
407     return self._data[name]
408
409   def get(self, name, default=None):
410     """Returns configuration value.
411
412     @type name: string
413     @param name: Name of configuration entry
414     @param default: Default value
415
416     """
417     return self._data.get(name, default)
418
419   def GetMasterNode(self):
420     """Returns the default master node for the cluster.
421
422     """
423     return self["nodes"][0]
424
425   def GetInstanceCheckScript(self):
426     """Returns path to instance check script or C{None}.
427
428     """
429     return self._data.get(_INSTANCE_CHECK_KEY, None)
430
431   def GetEnabledHypervisors(self):
432     """Returns list of enabled hypervisors.
433
434     @rtype: list
435
436     """
437     return self._GetStringListParameter(
438       _ENABLED_HV_KEY,
439       [constants.DEFAULT_ENABLED_HYPERVISOR])
440
441   def GetDefaultHypervisor(self):
442     """Returns the default hypervisor to be used.
443
444     """
445     return self.GetEnabledHypervisors()[0]
446
447   def GetEnabledDiskTemplates(self):
448     """Returns the list of enabled disk templates.
449
450     @rtype: list
451
452     """
453     return self._GetStringListParameter(
454       _ENABLED_DISK_TEMPLATES_KEY,
455       list(constants.DEFAULT_ENABLED_DISK_TEMPLATES))
456
457   def GetDefaultDiskTemplate(self):
458     """Returns the default disk template to be used.
459
460     """
461     return self.GetEnabledDiskTemplates()[0]
462
463   def _GetStringListParameter(self, key, default_values):
464     """Retrieves a parameter's value that is supposed to be a list of strings.
465
466     @rtype: list
467
468     """
469     try:
470       value = self._data[key]
471     except KeyError:
472       return default_values
473     else:
474       if value is None:
475         return []
476       elif isinstance(value, basestring):
477         return value.split(",")
478       else:
479         return value
480
481   def SetExclusiveStorage(self, value):
482     """Set the expected value of the C{exclusive_storage} flag for the cluster.
483
484     """
485     self._exclusive_storage = bool(value)
486
487   def GetExclusiveStorage(self):
488     """Get the expected value of the C{exclusive_storage} flag for the cluster.
489
490     """
491     value = self._exclusive_storage
492     assert value is not None
493     return value
494
495   def IsTemplateSupported(self, templ):
496     """Is the given disk template supported by the current configuration?
497
498     """
499     enabled = templ in self.GetEnabledDiskTemplates()
500     return enabled and (not self.GetExclusiveStorage() or
501                         templ in constants.DTS_EXCL_STORAGE)
502
503   def GetVclusterSettings(self):
504     """Returns settings for virtual cluster.
505
506     """
507     master = self.get(_VCLUSTER_MASTER_KEY)
508     basedir = self.get(_VCLUSTER_BASEDIR_KEY)
509
510     return (master, basedir)
511
512   def GetDiskOptions(self):
513     """Return options for the disks of the instances.
514
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.
517
518     """
519     try:
520       return self._data["disks"]
521     except KeyError:
522       pass
523
524     # Legacy interface
525     sizes = self._data.get("disk")
526     growths = self._data.get("disk-growth")
527     if sizes or growths:
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")
531       disks = []
532       for (size, growth) in zip(sizes, growths):
533         disks.append({"size": size, "growth": growth})
534       return disks
535     else:
536       return None
537
538
539 def Load(path):
540   """Loads the passed configuration file.
541
542   """
543   global _config # pylint: disable=W0603
544
545   _config = _QaConfig.Load(path)
546
547
548 def GetConfig():
549   """Returns the configuration object.
550
551   """
552   if _config is None:
553     raise RuntimeError("Configuration not yet loaded")
554
555   return _config
556
557
558 def get(name, default=None):
559   """Wrapper for L{_QaConfig.get}.
560
561   """
562   return GetConfig().get(name, default=default)
563
564
565 class Either:
566   def __init__(self, tests):
567     """Initializes this class.
568
569     @type tests: list or string
570     @param tests: List of test names
571     @see: L{TestEnabled} for details
572
573     """
574     self.tests = tests
575
576
577 def _MakeSequence(value):
578   """Make sequence of single argument.
579
580   If the single argument is not already a list or tuple, a list with the
581   argument as a single item is returned.
582
583   """
584   if isinstance(value, (list, tuple)):
585     return value
586   else:
587     return [value]
588
589
590 def _TestEnabledInner(check_fn, names, fn):
591   """Evaluate test conditions.
592
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)
597   @type fn: callable
598   @param fn: Aggregation function
599   @rtype: bool
600   @return: Whether test is enabled
601
602   """
603   names = _MakeSequence(names)
604
605   result = []
606
607   for name in 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)
612     elif callable(name):
613       value = name()
614     else:
615       value = check_fn(name)
616
617     result.append(value)
618
619   return fn(result)
620
621
622 def TestEnabled(tests, _cfg=None):
623   """Returns True if the given tests are enabled.
624
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
627
628   """
629   if _cfg is None:
630     cfg = GetConfig()
631   else:
632     cfg = _cfg
633
634   # Get settings for all tests
635   cfg_tests = cfg.get("tests", {})
636
637   # Get default setting
638   default = cfg_tests.get("default", True)
639
640   return _TestEnabledInner(lambda name: cfg_tests.get(name, default),
641                            tests, compat.all)
642
643
644 def GetInstanceCheckScript(*args):
645   """Wrapper for L{_QaConfig.GetInstanceCheckScript}.
646
647   """
648   return GetConfig().GetInstanceCheckScript(*args)
649
650
651 def GetEnabledHypervisors(*args):
652   """Wrapper for L{_QaConfig.GetEnabledHypervisors}.
653
654   """
655   return GetConfig().GetEnabledHypervisors(*args)
656
657
658 def GetDefaultHypervisor(*args):
659   """Wrapper for L{_QaConfig.GetDefaultHypervisor}.
660
661   """
662   return GetConfig().GetDefaultHypervisor(*args)
663
664
665 def GetEnabledDiskTemplates(*args):
666   """Wrapper for L{_QaConfig.GetEnabledDiskTemplates}.
667
668   """
669   return GetConfig().GetEnabledDiskTemplates(*args)
670
671
672 def GetDefaultDiskTemplate(*args):
673   """Wrapper for L{_QaConfig.GetDefaultDiskTemplate}.
674
675   """
676   return GetConfig().GetDefaultDiskTemplate(*args)
677
678
679 def GetMasterNode():
680   """Wrapper for L{_QaConfig.GetMasterNode}.
681
682   """
683   return GetConfig().GetMasterNode()
684
685
686 def AcquireInstance(_cfg=None):
687   """Returns an instance which isn't in use.
688
689   """
690   if _cfg is None:
691     cfg = GetConfig()
692   else:
693     cfg = _cfg
694
695   # Filter out unwanted instances
696   instances = filter(lambda inst: not inst.used, cfg["instances"])
697
698   if not instances:
699     raise qa_error.OutOfInstancesError("No instances left")
700
701   instance = instances[0]
702   instance.Use()
703
704   return instance
705
706
707 def SetExclusiveStorage(value):
708   """Wrapper for L{_QaConfig.SetExclusiveStorage}.
709
710   """
711   return GetConfig().SetExclusiveStorage(value)
712
713
714 def GetExclusiveStorage():
715   """Wrapper for L{_QaConfig.GetExclusiveStorage}.
716
717   """
718   return GetConfig().GetExclusiveStorage()
719
720
721 def IsTemplateSupported(templ):
722   """Wrapper for L{_QaConfig.IsTemplateSupported}.
723
724   """
725   return GetConfig().IsTemplateSupported(templ)
726
727
728 def _NodeSortKey(node):
729   """Returns sort key for a node.
730
731   @type node: L{_QaNode}
732
733   """
734   return (node.use_count, utils.NiceSortKey(node.primary))
735
736
737 def AcquireNode(exclude=None, _cfg=None):
738   """Returns the least used node.
739
740   """
741   if _cfg is None:
742     cfg = GetConfig()
743   else:
744     cfg = _cfg
745
746   master = cfg.GetMasterNode()
747
748   # Filter out unwanted nodes
749   # TODO: Maybe combine filters
750   if exclude is None:
751     nodes = cfg["nodes"][:]
752   elif isinstance(exclude, (list, tuple)):
753     nodes = filter(lambda node: node not in exclude, cfg["nodes"])
754   else:
755     nodes = filter(lambda node: node != exclude, cfg["nodes"])
756
757   nodes = filter(lambda node: node.added or node == master, nodes)
758
759   if not nodes:
760     raise qa_error.OutOfNodesError("No nodes left")
761
762   # Return node with least number of uses
763   return sorted(nodes, key=_NodeSortKey)[0].Use()
764
765
766 def AcquireManyNodes(num, exclude=None):
767   """Return the least used nodes.
768
769   @type num: int
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
775
776   """
777   nodes = []
778   if exclude is None:
779     exclude = []
780   elif isinstance(exclude, (list, tuple)):
781     # Don't modify the incoming argument
782     exclude = list(exclude)
783   else:
784     exclude = [exclude]
785
786   try:
787     for _ in range(0, num):
788       n = AcquireNode(exclude=exclude)
789       nodes.append(n)
790       exclude.append(n)
791   except qa_error.OutOfNodesError:
792     ReleaseManyNodes(nodes)
793     raise
794   return nodes
795
796
797 def ReleaseManyNodes(nodes):
798   for node in nodes:
799     node.Release()
800
801
802 def GetVclusterSettings():
803   """Wrapper for L{_QaConfig.GetVclusterSettings}.
804
805   """
806   return GetConfig().GetVclusterSettings()
807
808
809 def UseVirtualCluster(_cfg=None):
810   """Returns whether a virtual cluster is used.
811
812   @rtype: bool
813
814   """
815   if _cfg is None:
816     cfg = GetConfig()
817   else:
818     cfg = _cfg
819
820   (master, _) = cfg.GetVclusterSettings()
821
822   return bool(master)
823
824
825 @ht.WithDesc("No virtual cluster")
826 def NoVirtualCluster():
827   """Used to disable tests for virtual clusters.
828
829   """
830   return not UseVirtualCluster()
831
832
833 def GetDiskOptions():
834   """Wrapper for L{_QaConfig.GetDiskOptions}.
835
836   """
837   return GetConfig().GetDiskOptions()