Statistics
| Branch: | Tag: | Revision:

root / qa / qa_config.py @ bfca72bc

History | View | Annotate | Download (21.5 kB)

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
import qa_logging
36

    
37

    
38
_INSTANCE_CHECK_KEY = "instance-check"
39
_ENABLED_HV_KEY = "enabled-hypervisors"
40
_VCLUSTER_MASTER_KEY = "vcluster-master"
41
_VCLUSTER_BASEDIR_KEY = "vcluster-basedir"
42
_ENABLED_DISK_TEMPLATES_KEY = "enabled-disk-templates"
43

    
44
# The constants related to JSON patching (as per RFC6902) that modifies QA's
45
# configuration.
46
_QA_BASE_PATH = os.path.dirname(__file__)
47
_QA_DEFAULT_PATCH = "qa-patch.json"
48
_QA_PATCH_DIR = "patch"
49
_QA_PATCH_ORDER_FILE = "order"
50

    
51
#: QA configuration (L{_QaConfig})
52
_config = None
53

    
54

    
55
class _QaInstance(object):
56
  __slots__ = [
57
    "name",
58
    "nicmac",
59
    "_used",
60
    "_disk_template",
61
    ]
62

    
63
  def __init__(self, name, nicmac):
64
    """Initializes instances of this class.
65

66
    """
67
    self.name = name
68
    self.nicmac = nicmac
69
    self._used = None
70
    self._disk_template = None
71

    
72
  @classmethod
73
  def FromDict(cls, data):
74
    """Creates instance object from JSON dictionary.
75

76
    """
77
    nicmac = []
78

    
79
    macaddr = data.get("nic.mac/0")
80
    if macaddr:
81
      nicmac.append(macaddr)
82

    
83
    return cls(name=data["name"], nicmac=nicmac)
84

    
85
  def __repr__(self):
86
    status = [
87
      "%s.%s" % (self.__class__.__module__, self.__class__.__name__),
88
      "name=%s" % self.name,
89
      "nicmac=%s" % self.nicmac,
90
      "used=%s" % self._used,
91
      "disk_template=%s" % self._disk_template,
92
      ]
93

    
94
    return "<%s at %#x>" % (" ".join(status), id(self))
95

    
96
  def Use(self):
97
    """Marks instance as being in use.
98

99
    """
100
    assert not self._used
101
    assert self._disk_template is None
102

    
103
    self._used = True
104

    
105
  def Release(self):
106
    """Releases instance and makes it available again.
107

108
    """
109
    assert self._used, \
110
      ("Instance '%s' was never acquired or released more than once" %
111
       self.name)
112

    
113
    self._used = False
114
    self._disk_template = None
115

    
116
  def GetNicMacAddr(self, idx, default):
117
    """Returns MAC address for NIC.
118

119
    @type idx: int
120
    @param idx: NIC index
121
    @param default: Default value
122

123
    """
124
    if len(self.nicmac) > idx:
125
      return self.nicmac[idx]
126
    else:
127
      return default
128

    
129
  def SetDiskTemplate(self, template):
130
    """Set the disk template.
131

132
    """
133
    assert template in constants.DISK_TEMPLATES
134

    
135
    self._disk_template = template
136

    
137
  @property
138
  def used(self):
139
    """Returns boolean denoting whether instance is in use.
140

141
    """
142
    return self._used
143

    
144
  @property
145
  def disk_template(self):
146
    """Returns the current disk template.
147

148
    """
149
    return self._disk_template
150

    
151

    
152
class _QaNode(object):
153
  __slots__ = [
154
    "primary",
155
    "secondary",
156
    "_added",
157
    "_use_count",
158
    ]
159

    
160
  def __init__(self, primary, secondary):
161
    """Initializes instances of this class.
162

163
    """
164
    self.primary = primary
165
    self.secondary = secondary
166
    self._added = False
167
    self._use_count = 0
168

    
169
  @classmethod
170
  def FromDict(cls, data):
171
    """Creates node object from JSON dictionary.
172

173
    """
174
    return cls(primary=data["primary"], secondary=data.get("secondary"))
175

    
176
  def __repr__(self):
177
    status = [
178
      "%s.%s" % (self.__class__.__module__, self.__class__.__name__),
179
      "primary=%s" % self.primary,
180
      "secondary=%s" % self.secondary,
181
      "added=%s" % self._added,
182
      "use_count=%s" % self._use_count,
183
      ]
184

    
185
    return "<%s at %#x>" % (" ".join(status), id(self))
186

    
187
  def Use(self):
188
    """Marks a node as being in use.
189

190
    """
191
    assert self._use_count >= 0
192

    
193
    self._use_count += 1
194

    
195
    return self
196

    
197
  def Release(self):
198
    """Release a node (opposite of L{Use}).
199

200
    """
201
    assert self.use_count > 0
202

    
203
    self._use_count -= 1
204

    
205
  def MarkAdded(self):
206
    """Marks node as having been added to a cluster.
207

208
    """
209
    assert not self._added
210
    self._added = True
211

    
212
  def MarkRemoved(self):
213
    """Marks node as having been removed from a cluster.
214

215
    """
216
    assert self._added
217
    self._added = False
218

    
219
  @property
220
  def added(self):
221
    """Returns whether a node is part of a cluster.
222

223
    """
224
    return self._added
225

    
226
  @property
227
  def use_count(self):
228
    """Returns number of current uses (controlled by L{Use} and L{Release}).
229

230
    """
231
    return self._use_count
232

    
233

    
234
_RESOURCE_CONVERTER = {
235
  "instances": _QaInstance.FromDict,
236
  "nodes": _QaNode.FromDict,
237
  }
238

    
239

    
240
def _ConvertResources((key, value)):
241
  """Converts cluster resources in configuration to Python objects.
242

243
  """
244
  fn = _RESOURCE_CONVERTER.get(key, None)
245
  if fn:
246
    return (key, map(fn, value))
247
  else:
248
    return (key, value)
249

    
250

    
251
class _QaConfig(object):
252
  def __init__(self, data):
253
    """Initializes instances of this class.
254

255
    """
256
    self._data = data
257

    
258
    #: Cluster-wide run-time value of the exclusive storage flag
259
    self._exclusive_storage = None
260

    
261
  @staticmethod
262
  def LoadPatch(patch_dict, rel_path):
263
    """ Loads a single patch.
264

265
    @type patch_dict: dict of string to dict
266
    @param patch_dict: A dictionary storing patches by relative path.
267
    @type rel_path: string
268
    @param rel_path: The relative path to the patch, might or might not exist.
269

270
    """
271
    try:
272
      full_path = os.path.join(_QA_BASE_PATH, rel_path)
273
      patch = serializer.LoadJson(utils.ReadFile(full_path))
274
      patch_dict[rel_path] = patch
275
    except IOError:
276
      pass
277

    
278
  @staticmethod
279
  def LoadPatches():
280
    """ Finds and loads all patches supported by the QA.
281

282
    @rtype: dict of string to dict
283
    @return: A dictionary of relative path to patch content.
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 ApplyPatch(data, patch_module, patches, patch_path):
297
    """Applies a single patch.
298

299
    @type data: dict (deserialized json)
300
    @param data: The QA configuration
301
    @type patch_module: module
302
    @param patch_module: The json patch module, loaded dynamically
303
    @type patches: dict of string to dict
304
    @param patches: The dictionary of patch path to content
305
    @type patch_path: string
306
    @param patch_path: The path to the patch, relative to the QA directory
307

308
    @return: The modified configuration data.
309

310
    """
311
    patch_content = patches[patch_path]
312
    print qa_logging.FormatInfo("Applying patch %s" % patch_path)
313
    if not patch_content and patch_path != _QA_DEFAULT_PATCH:
314
      print qa_logging.FormatWarning("The patch %s added by the user is empty" %
315
                                     patch_path)
316
    data = patch_module.apply_patch(data, patch_content)
317

    
318
  @staticmethod
319
  def ApplyPatches(data, patch_module, patches):
320
    """Applies any patches present, and returns the modified QA configuration.
321

322
    First, patches from the patch directory are applied. They are ordered
323
    alphabetically, unless there is an ``order`` file present - any patches
324
    listed within are applied in that order, and any remaining ones in
325
    alphabetical order again. Finally, the default patch residing in the
326
    top-level QA directory is applied.
327

328
    @type data: dict (deserialized json)
329
    @param data: The QA configuration
330
    @type patch_module: module
331
    @param patch_module: The json patch module, loaded dynamically
332
    @type patches: dict of string to dict
333
    @param patches: The dictionary of patch path to content
334

335
    @return: The modified configuration data.
336

337
    """
338
    ordered_patches = []
339
    order_path = os.path.join(_QA_BASE_PATH, _QA_PATCH_DIR,
340
                              _QA_PATCH_ORDER_FILE)
341
    if os.path.exists(order_path):
342
      order_file = open(order_path, 'r')
343
      ordered_patches = order_file.read().splitlines()
344
      # Removes empty lines
345
      ordered_patches = filter(None, ordered_patches)
346

    
347
    # Add the patch dir
348
    ordered_patches = map(lambda x: os.path.join(_QA_PATCH_DIR, x),
349
                          ordered_patches)
350

    
351
    # First the ordered patches
352
    for patch in ordered_patches:
353
      if patch not in patches:
354
        raise qa_error.Error("Patch %s specified in the ordering file does not "
355
                             "exist" % patch)
356
      _QaConfig.ApplyPatch(data, patch_module, patches, patch)
357

    
358
    # Then the other non-default ones
359
    for patch in sorted(patches):
360
      if patch != _QA_DEFAULT_PATCH and patch not in ordered_patches:
361
        _QaConfig.ApplyPatch(data, patch_module, patches, patch)
362

    
363
    # Finally the default one
364
    if _QA_DEFAULT_PATCH in patches:
365
      _QaConfig.ApplyPatch(data, patch_module, patches, _QA_DEFAULT_PATCH)
366

    
367
    return data
368

    
369
  @classmethod
370
  def Load(cls, filename):
371
    """Loads a configuration file and produces a configuration object.
372

373
    @type filename: string
374
    @param filename: Path to configuration file
375
    @rtype: L{_QaConfig}
376

377
    """
378
    data = serializer.LoadJson(utils.ReadFile(filename))
379

    
380
    # Patch the document using JSON Patch (RFC6902) in file _PATCH_JSON, if
381
    # available
382
    try:
383
      patches = _QaConfig.LoadPatches()
384
      # Try to use the module only if there is a non-empty patch present
385
      if any(patches.values()):
386
        mod = __import__("jsonpatch", fromlist=[])
387
        data = _QaConfig.ApplyPatches(data, mod, patches)
388
    except IOError:
389
      pass
390
    except ImportError:
391
      raise qa_error.Error("For the QA JSON patching feature to work, you "
392
                           "need to install Python modules 'jsonpatch' and "
393
                           "'jsonpointer'.")
394

    
395
    result = cls(dict(map(_ConvertResources,
396
                          data.items()))) # pylint: disable=E1103
397
    result.Validate()
398

    
399
    return result
400

    
401
  def Validate(self):
402
    """Validates loaded configuration data.
403

404
    """
405
    if not self.get("name"):
406
      raise qa_error.Error("Cluster name is required")
407

    
408
    if not self.get("nodes"):
409
      raise qa_error.Error("Need at least one node")
410

    
411
    if not self.get("instances"):
412
      raise qa_error.Error("Need at least one instance")
413

    
414
    disks = self.GetDiskOptions()
415
    if disks is None:
416
      raise qa_error.Error("Config option 'disks' must exist")
417
    else:
418
      for d in disks:
419
        if d.get("size") is None or d.get("growth") is None:
420
          raise qa_error.Error("Config options `size` and `growth` must exist"
421
                               " for all `disks` items")
422
    check = self.GetInstanceCheckScript()
423
    if check:
424
      try:
425
        os.stat(check)
426
      except EnvironmentError, err:
427
        raise qa_error.Error("Can't find instance check script '%s': %s" %
428
                             (check, err))
429

    
430
    enabled_hv = frozenset(self.GetEnabledHypervisors())
431
    if not enabled_hv:
432
      raise qa_error.Error("No hypervisor is enabled")
433

    
434
    difference = enabled_hv - constants.HYPER_TYPES
435
    if difference:
436
      raise qa_error.Error("Unknown hypervisor(s) enabled: %s" %
437
                           utils.CommaJoin(difference))
438

    
439
    (vc_master, vc_basedir) = self.GetVclusterSettings()
440
    if bool(vc_master) != bool(vc_basedir):
441
      raise qa_error.Error("All or none of the config options '%s' and '%s'"
442
                           " must be set" %
443
                           (_VCLUSTER_MASTER_KEY, _VCLUSTER_BASEDIR_KEY))
444

    
445
    if vc_basedir and not utils.IsNormAbsPath(vc_basedir):
446
      raise qa_error.Error("Path given in option '%s' must be absolute and"
447
                           " normalized" % _VCLUSTER_BASEDIR_KEY)
448

    
449
  def __getitem__(self, name):
450
    """Returns configuration value.
451

452
    @type name: string
453
    @param name: Name of configuration entry
454

455
    """
456
    return self._data[name]
457

    
458
  def get(self, name, default=None):
459
    """Returns configuration value.
460

461
    @type name: string
462
    @param name: Name of configuration entry
463
    @param default: Default value
464

465
    """
466
    return self._data.get(name, default)
467

    
468
  def GetMasterNode(self):
469
    """Returns the default master node for the cluster.
470

471
    """
472
    return self["nodes"][0]
473

    
474
  def GetInstanceCheckScript(self):
475
    """Returns path to instance check script or C{None}.
476

477
    """
478
    return self._data.get(_INSTANCE_CHECK_KEY, None)
479

    
480
  def GetEnabledHypervisors(self):
481
    """Returns list of enabled hypervisors.
482

483
    @rtype: list
484

485
    """
486
    return self._GetStringListParameter(
487
      _ENABLED_HV_KEY,
488
      [constants.DEFAULT_ENABLED_HYPERVISOR])
489

    
490
  def GetDefaultHypervisor(self):
491
    """Returns the default hypervisor to be used.
492

493
    """
494
    return self.GetEnabledHypervisors()[0]
495

    
496
  def GetEnabledDiskTemplates(self):
497
    """Returns the list of enabled disk templates.
498

499
    @rtype: list
500

501
    """
502
    return self._GetStringListParameter(
503
      _ENABLED_DISK_TEMPLATES_KEY,
504
      list(constants.DEFAULT_ENABLED_DISK_TEMPLATES))
505

    
506
  def GetDefaultDiskTemplate(self):
507
    """Returns the default disk template to be used.
508

509
    """
510
    return self.GetEnabledDiskTemplates()[0]
511

    
512
  def _GetStringListParameter(self, key, default_values):
513
    """Retrieves a parameter's value that is supposed to be a list of strings.
514

515
    @rtype: list
516

517
    """
518
    try:
519
      value = self._data[key]
520
    except KeyError:
521
      return default_values
522
    else:
523
      if value is None:
524
        return []
525
      elif isinstance(value, basestring):
526
        return value.split(",")
527
      else:
528
        return value
529

    
530
  def SetExclusiveStorage(self, value):
531
    """Set the expected value of the C{exclusive_storage} flag for the cluster.
532

533
    """
534
    self._exclusive_storage = bool(value)
535

    
536
  def GetExclusiveStorage(self):
537
    """Get the expected value of the C{exclusive_storage} flag for the cluster.
538

539
    """
540
    value = self._exclusive_storage
541
    assert value is not None
542
    return value
543

    
544
  def IsTemplateSupported(self, templ):
545
    """Is the given disk template supported by the current configuration?
546

547
    """
548
    enabled = templ in self.GetEnabledDiskTemplates()
549
    return enabled and (not self.GetExclusiveStorage() or
550
                        templ in constants.DTS_EXCL_STORAGE)
551

    
552
  def GetVclusterSettings(self):
553
    """Returns settings for virtual cluster.
554

555
    """
556
    master = self.get(_VCLUSTER_MASTER_KEY)
557
    basedir = self.get(_VCLUSTER_BASEDIR_KEY)
558

    
559
    return (master, basedir)
560

    
561
  def GetDiskOptions(self):
562
    """Return options for the disks of the instances.
563

564
    Get 'disks' parameter from the configuration data. If 'disks' is missing,
565
    try to create it from the legacy 'disk' and 'disk-growth' parameters.
566

567
    """
568
    try:
569
      return self._data["disks"]
570
    except KeyError:
571
      pass
572

    
573
    # Legacy interface
574
    sizes = self._data.get("disk")
575
    growths = self._data.get("disk-growth")
576
    if sizes or growths:
577
      if (sizes is None or growths is None or len(sizes) != len(growths)):
578
        raise qa_error.Error("Config options 'disk' and 'disk-growth' must"
579
                             " exist and have the same number of items")
580
      disks = []
581
      for (size, growth) in zip(sizes, growths):
582
        disks.append({"size": size, "growth": growth})
583
      return disks
584
    else:
585
      return None
586

    
587

    
588
def Load(path):
589
  """Loads the passed configuration file.
590

591
  """
592
  global _config # pylint: disable=W0603
593

    
594
  _config = _QaConfig.Load(path)
595

    
596

    
597
def GetConfig():
598
  """Returns the configuration object.
599

600
  """
601
  if _config is None:
602
    raise RuntimeError("Configuration not yet loaded")
603

    
604
  return _config
605

    
606

    
607
def get(name, default=None):
608
  """Wrapper for L{_QaConfig.get}.
609

610
  """
611
  return GetConfig().get(name, default=default)
612

    
613

    
614
class Either:
615
  def __init__(self, tests):
616
    """Initializes this class.
617

618
    @type tests: list or string
619
    @param tests: List of test names
620
    @see: L{TestEnabled} for details
621

622
    """
623
    self.tests = tests
624

    
625

    
626
def _MakeSequence(value):
627
  """Make sequence of single argument.
628

629
  If the single argument is not already a list or tuple, a list with the
630
  argument as a single item is returned.
631

632
  """
633
  if isinstance(value, (list, tuple)):
634
    return value
635
  else:
636
    return [value]
637

    
638

    
639
def _TestEnabledInner(check_fn, names, fn):
640
  """Evaluate test conditions.
641

642
  @type check_fn: callable
643
  @param check_fn: Callback to check whether a test is enabled
644
  @type names: sequence or string
645
  @param names: Test name(s)
646
  @type fn: callable
647
  @param fn: Aggregation function
648
  @rtype: bool
649
  @return: Whether test is enabled
650

651
  """
652
  names = _MakeSequence(names)
653

    
654
  result = []
655

    
656
  for name in names:
657
    if isinstance(name, Either):
658
      value = _TestEnabledInner(check_fn, name.tests, compat.any)
659
    elif isinstance(name, (list, tuple)):
660
      value = _TestEnabledInner(check_fn, name, compat.all)
661
    elif callable(name):
662
      value = name()
663
    else:
664
      value = check_fn(name)
665

    
666
    result.append(value)
667

    
668
  return fn(result)
669

    
670

    
671
def TestEnabled(tests, _cfg=None):
672
  """Returns True if the given tests are enabled.
673

674
  @param tests: A single test as a string, or a list of tests to check; can
675
    contain L{Either} for OR conditions, AND is default
676

677
  """
678
  if _cfg is None:
679
    cfg = GetConfig()
680
  else:
681
    cfg = _cfg
682

    
683
  # Get settings for all tests
684
  cfg_tests = cfg.get("tests", {})
685

    
686
  # Get default setting
687
  default = cfg_tests.get("default", True)
688

    
689
  return _TestEnabledInner(lambda name: cfg_tests.get(name, default),
690
                           tests, compat.all)
691

    
692

    
693
def GetInstanceCheckScript(*args):
694
  """Wrapper for L{_QaConfig.GetInstanceCheckScript}.
695

696
  """
697
  return GetConfig().GetInstanceCheckScript(*args)
698

    
699

    
700
def GetEnabledHypervisors(*args):
701
  """Wrapper for L{_QaConfig.GetEnabledHypervisors}.
702

703
  """
704
  return GetConfig().GetEnabledHypervisors(*args)
705

    
706

    
707
def GetDefaultHypervisor(*args):
708
  """Wrapper for L{_QaConfig.GetDefaultHypervisor}.
709

710
  """
711
  return GetConfig().GetDefaultHypervisor(*args)
712

    
713

    
714
def GetEnabledDiskTemplates(*args):
715
  """Wrapper for L{_QaConfig.GetEnabledDiskTemplates}.
716

717
  """
718
  return GetConfig().GetEnabledDiskTemplates(*args)
719

    
720

    
721
def GetDefaultDiskTemplate(*args):
722
  """Wrapper for L{_QaConfig.GetDefaultDiskTemplate}.
723

724
  """
725
  return GetConfig().GetDefaultDiskTemplate(*args)
726

    
727

    
728
def GetMasterNode():
729
  """Wrapper for L{_QaConfig.GetMasterNode}.
730

731
  """
732
  return GetConfig().GetMasterNode()
733

    
734

    
735
def AcquireInstance(_cfg=None):
736
  """Returns an instance which isn't in use.
737

738
  """
739
  if _cfg is None:
740
    cfg = GetConfig()
741
  else:
742
    cfg = _cfg
743

    
744
  # Filter out unwanted instances
745
  instances = filter(lambda inst: not inst.used, cfg["instances"])
746

    
747
  if not instances:
748
    raise qa_error.OutOfInstancesError("No instances left")
749

    
750
  instance = instances[0]
751
  instance.Use()
752

    
753
  return instance
754

    
755

    
756
def SetExclusiveStorage(value):
757
  """Wrapper for L{_QaConfig.SetExclusiveStorage}.
758

759
  """
760
  return GetConfig().SetExclusiveStorage(value)
761

    
762

    
763
def GetExclusiveStorage():
764
  """Wrapper for L{_QaConfig.GetExclusiveStorage}.
765

766
  """
767
  return GetConfig().GetExclusiveStorage()
768

    
769

    
770
def IsTemplateSupported(templ):
771
  """Wrapper for L{_QaConfig.IsTemplateSupported}.
772

773
  """
774
  return GetConfig().IsTemplateSupported(templ)
775

    
776

    
777
def _NodeSortKey(node):
778
  """Returns sort key for a node.
779

780
  @type node: L{_QaNode}
781

782
  """
783
  return (node.use_count, utils.NiceSortKey(node.primary))
784

    
785

    
786
def AcquireNode(exclude=None, _cfg=None):
787
  """Returns the least used node.
788

789
  """
790
  if _cfg is None:
791
    cfg = GetConfig()
792
  else:
793
    cfg = _cfg
794

    
795
  master = cfg.GetMasterNode()
796

    
797
  # Filter out unwanted nodes
798
  # TODO: Maybe combine filters
799
  if exclude is None:
800
    nodes = cfg["nodes"][:]
801
  elif isinstance(exclude, (list, tuple)):
802
    nodes = filter(lambda node: node not in exclude, cfg["nodes"])
803
  else:
804
    nodes = filter(lambda node: node != exclude, cfg["nodes"])
805

    
806
  nodes = filter(lambda node: node.added or node == master, nodes)
807

    
808
  if not nodes:
809
    raise qa_error.OutOfNodesError("No nodes left")
810

    
811
  # Return node with least number of uses
812
  return sorted(nodes, key=_NodeSortKey)[0].Use()
813

    
814

    
815
def AcquireManyNodes(num, exclude=None):
816
  """Return the least used nodes.
817

818
  @type num: int
819
  @param num: Number of nodes; can be 0.
820
  @type exclude: list of nodes or C{None}
821
  @param exclude: nodes to be excluded from the choice
822
  @rtype: list of nodes
823
  @return: C{num} different nodes
824

825
  """
826
  nodes = []
827
  if exclude is None:
828
    exclude = []
829
  elif isinstance(exclude, (list, tuple)):
830
    # Don't modify the incoming argument
831
    exclude = list(exclude)
832
  else:
833
    exclude = [exclude]
834

    
835
  try:
836
    for _ in range(0, num):
837
      n = AcquireNode(exclude=exclude)
838
      nodes.append(n)
839
      exclude.append(n)
840
  except qa_error.OutOfNodesError:
841
    ReleaseManyNodes(nodes)
842
    raise
843
  return nodes
844

    
845

    
846
def ReleaseManyNodes(nodes):
847
  for node in nodes:
848
    node.Release()
849

    
850

    
851
def GetVclusterSettings():
852
  """Wrapper for L{_QaConfig.GetVclusterSettings}.
853

854
  """
855
  return GetConfig().GetVclusterSettings()
856

    
857

    
858
def UseVirtualCluster(_cfg=None):
859
  """Returns whether a virtual cluster is used.
860

861
  @rtype: bool
862

863
  """
864
  if _cfg is None:
865
    cfg = GetConfig()
866
  else:
867
    cfg = _cfg
868

    
869
  (master, _) = cfg.GetVclusterSettings()
870

    
871
  return bool(master)
872

    
873

    
874
@ht.WithDesc("No virtual cluster")
875
def NoVirtualCluster():
876
  """Used to disable tests for virtual clusters.
877

878
  """
879
  return not UseVirtualCluster()
880

    
881

    
882
def GetDiskOptions():
883
  """Wrapper for L{_QaConfig.GetDiskOptions}.
884

885
  """
886
  return GetConfig().GetDiskOptions()