client.py 48.5 KB
Newer Older
David Knowles's avatar
David Knowles committed
1
2
3
#
#

4
# Copyright (C) 2010, 2011 Google Inc.
David Knowles's avatar
David Knowles committed
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.


22
23
24
25
26
27
28
29
30
31
"""Ganeti RAPI client.

@attention: To use the RAPI client, the application B{must} call
            C{pycurl.global_init} during initialization and
            C{pycurl.global_cleanup} before exiting the process. This is very
            important in multi-threaded programs. See curl_global_init(3) and
            curl_global_cleanup(3) for details. The decorator L{UsesRapiClient}
            can be used.

"""
David Knowles's avatar
David Knowles committed
32

33
34
35
# No Ganeti-specific modules should be imported. The RAPI client is supposed to
# be standalone.

36
import logging
David Knowles's avatar
David Knowles committed
37
import simplejson
38
import socket
David Knowles's avatar
David Knowles committed
39
import urllib
40
41
import threading
import pycurl
42
import time
43
44
45
46
47

try:
  from cStringIO import StringIO
except ImportError:
  from StringIO import StringIO
David Knowles's avatar
David Knowles committed
48
49


50
GANETI_RAPI_PORT = 5080
51
GANETI_RAPI_VERSION = 2
52

David Knowles's avatar
David Knowles committed
53
54
55
56
HTTP_DELETE = "DELETE"
HTTP_GET = "GET"
HTTP_PUT = "PUT"
HTTP_POST = "POST"
57
HTTP_OK = 200
58
HTTP_NOT_FOUND = 404
59
60
HTTP_APP_JSON = "application/json"

David Knowles's avatar
David Knowles committed
61
62
63
64
REPLACE_DISK_PRI = "replace_on_primary"
REPLACE_DISK_SECONDARY = "replace_on_secondary"
REPLACE_DISK_CHG = "replace_new_secondary"
REPLACE_DISK_AUTO = "replace_auto"
65
66
67
68
69
70

NODE_ROLE_DRAINED = "drained"
NODE_ROLE_MASTER_CANDIATE = "master-candidate"
NODE_ROLE_MASTER = "master"
NODE_ROLE_OFFLINE = "offline"
NODE_ROLE_REGULAR = "regular"
David Knowles's avatar
David Knowles committed
71

72
73
74
# Internal constants
_REQ_DATA_VERSION_FIELD = "__version__"
_INST_CREATE_REQV1 = "instance-create-reqv1"
75
_INST_REINSTALL_REQV1 = "instance-reinstall-reqv1"
76
_INST_NIC_PARAMS = frozenset(["mac", "ip", "mode", "link"])
77
78
79
80
81
82
_INST_CREATE_V0_DISK_PARAMS = frozenset(["size"])
_INST_CREATE_V0_PARAMS = frozenset([
  "os", "pnode", "snode", "iallocator", "start", "ip_check", "name_check",
  "hypervisor", "file_storage_dir", "file_driver", "dry_run",
  ])
_INST_CREATE_V0_DPARAMS = frozenset(["beparams", "hvparams"])
83

84
85
86
87
88
89
90
91
92
93
94
95
96
# Older pycURL versions don't have all error constants
try:
  _CURLE_SSL_CACERT = pycurl.E_SSL_CACERT
  _CURLE_SSL_CACERT_BADFILE = pycurl.E_SSL_CACERT_BADFILE
except AttributeError:
  _CURLE_SSL_CACERT = 60
  _CURLE_SSL_CACERT_BADFILE = 77

_CURL_SSL_CERT_ERRORS = frozenset([
  _CURLE_SSL_CACERT,
  _CURLE_SSL_CACERT_BADFILE,
  ])

David Knowles's avatar
David Knowles committed
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115

class Error(Exception):
  """Base error class for this module.

  """
  pass


class CertificateError(Error):
  """Raised when a problem is found with the SSL certificate.

  """
  pass


class GanetiApiError(Error):
  """Generic error raised from Ganeti API.

  """
116
117
118
  def __init__(self, msg, code=None):
    Error.__init__(self, msg)
    self.code = code
David Knowles's avatar
David Knowles committed
119
120


121
122
def UsesRapiClient(fn):
  """Decorator for code using RAPI client to initialize pycURL.
123
124

  """
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
  def wrapper(*args, **kwargs):
    # curl_global_init(3) and curl_global_cleanup(3) must be called with only
    # one thread running. This check is just a safety measure -- it doesn't
    # cover all cases.
    assert threading.activeCount() == 1, \
           "Found active threads when initializing pycURL"

    pycurl.global_init(pycurl.GLOBAL_ALL)
    try:
      return fn(*args, **kwargs)
    finally:
      pycurl.global_cleanup()

  return wrapper


def GenericCurlConfig(verbose=False, use_signal=False,
                      use_curl_cabundle=False, cafile=None, capath=None,
                      proxy=None, verify_hostname=False,
                      connect_timeout=None, timeout=None,
                      _pycurl_version_fn=pycurl.version_info):
  """Curl configuration function generator.

  @type verbose: bool
  @param verbose: Whether to set cURL to verbose mode
  @type use_signal: bool
  @param use_signal: Whether to allow cURL to use signals
  @type use_curl_cabundle: bool
  @param use_curl_cabundle: Whether to use cURL's default CA bundle
  @type cafile: string
  @param cafile: In which file we can find the certificates
  @type capath: string
  @param capath: In which directory we can find the certificates
  @type proxy: string
  @param proxy: Proxy to use, None for default behaviour and empty string for
                disabling proxies (see curl_easy_setopt(3))
  @type verify_hostname: bool
  @param verify_hostname: Whether to verify the remote peer certificate's
                          commonName
  @type connect_timeout: number
  @param connect_timeout: Timeout for establishing connection in seconds
  @type timeout: number
  @param timeout: Timeout for complete transfer in seconds (see
                  curl_easy_setopt(3)).
169
170

  """
171
172
  if use_curl_cabundle and (cafile or capath):
    raise Error("Can not use default CA bundle when CA file or path is set")
173

174
175
  def _ConfigCurl(curl, logger):
    """Configures a cURL object
176

177
178
    @type curl: pycurl.Curl
    @param curl: cURL object
179
180

    """
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
    logger.debug("Using cURL version %s", pycurl.version)

    # pycurl.version_info returns a tuple with information about the used
    # version of libcurl. Item 5 is the SSL library linked to it.
    # e.g.: (3, '7.18.0', 463360, 'x86_64-pc-linux-gnu', 1581, 'GnuTLS/2.0.4',
    # 0, '1.2.3.3', ...)
    sslver = _pycurl_version_fn()[5]
    if not sslver:
      raise Error("No SSL support in cURL")

    lcsslver = sslver.lower()
    if lcsslver.startswith("openssl/"):
      pass
    elif lcsslver.startswith("gnutls/"):
      if capath:
        raise Error("cURL linked against GnuTLS has no support for a"
                    " CA path (%s)" % (pycurl.version, ))
198
    else:
199
200
201
202
203
204
205
206
207
208
209
210
211
212
      raise NotImplementedError("cURL uses unsupported SSL version '%s'" %
                                sslver)

    curl.setopt(pycurl.VERBOSE, verbose)
    curl.setopt(pycurl.NOSIGNAL, not use_signal)

    # Whether to verify remote peer's CN
    if verify_hostname:
      # curl_easy_setopt(3): "When CURLOPT_SSL_VERIFYHOST is 2, that
      # certificate must indicate that the server is the server to which you
      # meant to connect, or the connection fails. [...] When the value is 1,
      # the certificate must contain a Common Name field, but it doesn't matter
      # what name it says. [...]"
      curl.setopt(pycurl.SSL_VERIFYHOST, 2)
213
    else:
214
215
216
217
218
219
220
221
222
223
224
225
226
      curl.setopt(pycurl.SSL_VERIFYHOST, 0)

    if cafile or capath or use_curl_cabundle:
      # Require certificates to be checked
      curl.setopt(pycurl.SSL_VERIFYPEER, True)
      if cafile:
        curl.setopt(pycurl.CAINFO, str(cafile))
      if capath:
        curl.setopt(pycurl.CAPATH, str(capath))
      # Not changing anything for using default CA bundle
    else:
      # Disable SSL certificate verification
      curl.setopt(pycurl.SSL_VERIFYPEER, False)
227

228
229
    if proxy is not None:
      curl.setopt(pycurl.PROXY, str(proxy))
230

231
232
233
234
235
    # Timeouts
    if connect_timeout is not None:
      curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
    if timeout is not None:
      curl.setopt(pycurl.TIMEOUT, timeout)
236

237
  return _ConfigCurl
238
239


240
class GanetiRapiClient(object): # pylint: disable-msg=R0904
David Knowles's avatar
David Knowles committed
241
242
243
244
  """Ganeti RAPI client.

  """
  USER_AGENT = "Ganeti RAPI Client"
245
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
David Knowles's avatar
David Knowles committed
246

247
  def __init__(self, host, port=GANETI_RAPI_PORT,
248
               username=None, password=None, logger=logging,
249
               curl_config_fn=None, curl_factory=None):
250
    """Initializes this class.
David Knowles's avatar
David Knowles committed
251

252
253
    @type host: string
    @param host: the ganeti cluster master to interact with
David Knowles's avatar
David Knowles committed
254
    @type port: int
255
256
    @param port: the port on which the RAPI is running (default is 5080)
    @type username: string
David Knowles's avatar
David Knowles committed
257
    @param username: the username to connect with
258
    @type password: string
David Knowles's avatar
David Knowles committed
259
    @param password: the password to connect with
260
261
    @type curl_config_fn: callable
    @param curl_config_fn: Function to configure C{pycurl.Curl} object
262
    @param logger: Logging object
David Knowles's avatar
David Knowles committed
263
264

    """
265
266
    self._username = username
    self._password = password
267
    self._logger = logger
268
269
    self._curl_config_fn = curl_config_fn
    self._curl_factory = curl_factory
David Knowles's avatar
David Knowles committed
270

271
272
273
274
275
276
277
    try:
      socket.inet_pton(socket.AF_INET6, host)
      address = "[%s]:%s" % (host, port)
    except socket.error:
      address = "%s:%s" % (host, port)

    self._base_url = "https://%s" % address
David Knowles's avatar
David Knowles committed
278

279
280
281
282
283
284
285
286
287
288
289
290
291
292
    if username is not None:
      if password is None:
        raise Error("Password not specified")
    elif password:
      raise Error("Specified password without username")

  def _CreateCurl(self):
    """Creates a cURL object.

    """
    # Create pycURL object if no factory is provided
    if self._curl_factory:
      curl = self._curl_factory()
    else:
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
      curl = pycurl.Curl()

    # Default cURL settings
    curl.setopt(pycurl.VERBOSE, False)
    curl.setopt(pycurl.FOLLOWLOCATION, False)
    curl.setopt(pycurl.MAXREDIRS, 5)
    curl.setopt(pycurl.NOSIGNAL, True)
    curl.setopt(pycurl.USERAGENT, self.USER_AGENT)
    curl.setopt(pycurl.SSL_VERIFYHOST, 0)
    curl.setopt(pycurl.SSL_VERIFYPEER, False)
    curl.setopt(pycurl.HTTPHEADER, [
      "Accept: %s" % HTTP_APP_JSON,
      "Content-type: %s" % HTTP_APP_JSON,
      ])

308
309
310
311
312
    assert ((self._username is None and self._password is None) ^
            (self._username is not None and self._password is not None))

    if self._username:
      # Setup authentication
313
      curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
314
315
      curl.setopt(pycurl.USERPWD,
                  str("%s:%s" % (self._username, self._password)))
316

317
    # Call external configuration function
318
319
    if self._curl_config_fn:
      self._curl_config_fn(curl, self._logger)
David Knowles's avatar
David Knowles committed
320

321
    return curl
David Knowles's avatar
David Knowles committed
322

323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
  @staticmethod
  def _EncodeQuery(query):
    """Encode query values for RAPI URL.

    @type query: list of two-tuples
    @param query: Query arguments
    @rtype: list
    @return: Query list with encoded values

    """
    result = []

    for name, value in query:
      if value is None:
        result.append((name, ""))

      elif isinstance(value, bool):
        # Boolean values must be encoded as 0 or 1
        result.append((name, int(value)))

      elif isinstance(value, (list, tuple, dict)):
        raise ValueError("Invalid query data type %r" % type(value).__name__)

      else:
        result.append((name, value))

    return result

351
  def _SendRequest(self, method, path, query, content):
David Knowles's avatar
David Knowles committed
352
353
354
355
356
    """Sends an HTTP request.

    This constructs a full URL, encodes and decodes HTTP bodies, and
    handles invalid responses in a pythonic way.

357
    @type method: string
David Knowles's avatar
David Knowles committed
358
    @param method: HTTP method to use
359
    @type path: string
David Knowles's avatar
David Knowles committed
360
361
362
363
364
365
366
367
368
    @param path: HTTP URL path
    @type query: list of two-tuples
    @param query: query arguments to pass to urllib.urlencode
    @type content: str or None
    @param content: HTTP body content

    @rtype: str
    @return: JSON-Decoded response

David Knowles's avatar
David Knowles committed
369
    @raises CertificateError: If an invalid SSL certificate is found
David Knowles's avatar
David Knowles committed
370
371
372
    @raises GanetiApiError: If an invalid response is returned

    """
373
374
    assert path.startswith("/")

375
    curl = self._CreateCurl()
376

377
    if content is not None:
378
379
      encoded_content = self._json_encoder.encode(content)
    else:
380
      encoded_content = ""
David Knowles's avatar
David Knowles committed
381

382
    # Build URL
383
    urlparts = [self._base_url, path]
384
    if query:
385
386
      urlparts.append("?")
      urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
387

388
389
    url = "".join(urlparts)

390
391
    self._logger.debug("Sending request %s %s (content=%r)",
                       method, url, encoded_content)
392
393
394

    # Buffer for response
    encoded_resp_body = StringIO()
395

396
397
398
399
400
    # Configure cURL
    curl.setopt(pycurl.CUSTOMREQUEST, str(method))
    curl.setopt(pycurl.URL, str(url))
    curl.setopt(pycurl.POSTFIELDS, str(encoded_content))
    curl.setopt(pycurl.WRITEFUNCTION, encoded_resp_body.write)
401

David Knowles's avatar
David Knowles committed
402
    try:
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
      # Send request and wait for response
      try:
        curl.perform()
      except pycurl.error, err:
        if err.args[0] in _CURL_SSL_CERT_ERRORS:
          raise CertificateError("SSL certificate error %s" % err)

        raise GanetiApiError(str(err))
    finally:
      # Reset settings to not keep references to large objects in memory
      # between requests
      curl.setopt(pycurl.POSTFIELDS, "")
      curl.setopt(pycurl.WRITEFUNCTION, lambda _: None)

    # Get HTTP response code
    http_code = curl.getinfo(pycurl.RESPONSE_CODE)

    # Was anything written to the response buffer?
    if encoded_resp_body.tell():
      response_content = simplejson.loads(encoded_resp_body.getvalue())
423
424
    else:
      response_content = None
David Knowles's avatar
David Knowles committed
425

426
    if http_code != HTTP_OK:
427
      if isinstance(response_content, dict):
David Knowles's avatar
David Knowles committed
428
        msg = ("%s %s: %s" %
429
430
431
               (response_content["code"],
                response_content["message"],
                response_content["explain"]))
David Knowles's avatar
David Knowles committed
432
      else:
433
434
        msg = str(response_content)

435
      raise GanetiApiError(msg, code=http_code)
David Knowles's avatar
David Knowles committed
436

437
    return response_content
David Knowles's avatar
David Knowles committed
438
439

  def GetVersion(self):
440
    """Gets the Remote API version running on the cluster.
David Knowles's avatar
David Knowles committed
441
442

    @rtype: int
David Knowles's avatar
David Knowles committed
443
    @return: Ganeti Remote API version
David Knowles's avatar
David Knowles committed
444
445

    """
446
    return self._SendRequest(HTTP_GET, "/version", None, None)
David Knowles's avatar
David Knowles committed
447

448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
  def GetFeatures(self):
    """Gets the list of optional features supported by RAPI server.

    @rtype: list
    @return: List of optional features

    """
    try:
      return self._SendRequest(HTTP_GET, "/%s/features" % GANETI_RAPI_VERSION,
                               None, None)
    except GanetiApiError, err:
      # Older RAPI servers don't support this resource
      if err.code == HTTP_NOT_FOUND:
        return []

      raise

David Knowles's avatar
David Knowles committed
465
466
467
468
469
470
471
  def GetOperatingSystems(self):
    """Gets the Operating Systems running in the Ganeti cluster.

    @rtype: list of str
    @return: operating systems

    """
472
473
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
474
475
476
477
478
479
480
481

  def GetInfo(self):
    """Gets info about the cluster.

    @rtype: dict
    @return: information about the cluster

    """
482
483
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
484

485
486
487
488
489
490
491
492
493
494
  def RedistributeConfig(self):
    """Tells the cluster to redistribute its configuration files.

    @return: job id

    """
    return self._SendRequest(HTTP_PUT,
                             "/%s/redistribute-config" % GANETI_RAPI_VERSION,
                             None, None)

495
496
497
498
499
500
501
502
503
504
505
506
507
508
  def ModifyCluster(self, **kwargs):
    """Modifies cluster parameters.

    More details for parameters can be found in the RAPI documentation.

    @rtype: int
    @return: job id

    """
    body = kwargs

    return self._SendRequest(HTTP_PUT,
                             "/%s/modify" % GANETI_RAPI_VERSION, None, body)

David Knowles's avatar
David Knowles committed
509
510
511
512
513
514
515
  def GetClusterTags(self):
    """Gets the cluster tags.

    @rtype: list of str
    @return: cluster tags

    """
516
517
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534

  def AddClusterTags(self, tags, dry_run=False):
    """Adds tags to the cluster.

    @type tags: list of str
    @param tags: tags to add to the cluster
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

535
536
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
537
538
539
540
541
542
543
544
545
546
547
548
549
550

  def DeleteClusterTags(self, tags, dry_run=False):
    """Deletes tags from the cluster.

    @type tags: list of str
    @param tags: tags to delete
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

551
552
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567

  def GetInstances(self, bulk=False):
    """Gets information about instances on the cluster.

    @type bulk: bool
    @param bulk: whether to return all information about all instances

    @rtype: list of dict or list of str
    @return: if bulk is True, info about the instances, else a list of instances

    """
    query = []
    if bulk:
      query.append(("bulk", 1))

568
569
570
    instances = self._SendRequest(HTTP_GET,
                                  "/%s/instances" % GANETI_RAPI_VERSION,
                                  query, None)
David Knowles's avatar
David Knowles committed
571
572
573
574
575
    if bulk:
      return instances
    else:
      return [i["id"] for i in instances]

576
  def GetInstance(self, instance):
David Knowles's avatar
David Knowles committed
577
578
579
580
581
582
583
584
585
    """Gets information about an instance.

    @type instance: str
    @param instance: instance whose info to return

    @rtype: dict
    @return: info about the instance

    """
586
587
588
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s" %
                              (GANETI_RAPI_VERSION, instance)), None, None)
David Knowles's avatar
David Knowles committed
589

590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
  def GetInstanceInfo(self, instance, static=None):
    """Gets information about an instance.

    @type instance: string
    @param instance: Instance name
    @rtype: string
    @return: Job ID

    """
    if static is not None:
      query = [("static", static)]
    else:
      query = None

    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/info" %
                              (GANETI_RAPI_VERSION, instance)), query, None)

608
609
  def CreateInstance(self, mode, name, disk_template, disks, nics,
                     **kwargs):
David Knowles's avatar
David Knowles committed
610
611
    """Creates a new instance.

612
613
614
615
616
617
618
619
620
621
622
623
624
    More details for parameters can be found in the RAPI documentation.

    @type mode: string
    @param mode: Instance creation mode
    @type name: string
    @param name: Hostname of the instance to create
    @type disk_template: string
    @param disk_template: Disk template for instance (e.g. plain, diskless,
                          file, or drbd)
    @type disks: list of dicts
    @param disks: List of disk definitions
    @type nics: list of dicts
    @param nics: List of NIC definitions
David Knowles's avatar
David Knowles committed
625
    @type dry_run: bool
626
    @keyword dry_run: whether to perform a dry run
David Knowles's avatar
David Knowles committed
627
628
629
630
631
632

    @rtype: int
    @return: job id

    """
    query = []
633
634

    if kwargs.get("dry_run"):
David Knowles's avatar
David Knowles committed
635
636
      query.append(("dry-run", 1))

637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
    if _INST_CREATE_REQV1 in self.GetFeatures():
      # All required fields for request data version 1
      body = {
        _REQ_DATA_VERSION_FIELD: 1,
        "mode": mode,
        "name": name,
        "disk_template": disk_template,
        "disks": disks,
        "nics": nics,
        }

      conflicts = set(kwargs.iterkeys()) & set(body.iterkeys())
      if conflicts:
        raise GanetiApiError("Required fields can not be specified as"
                             " keywords: %s" % ", ".join(conflicts))

      body.update((key, value) for key, value in kwargs.iteritems()
                  if key != "dry_run")
    else:
656
657
658
659
660
      # Old request format (version 0)

      # The following code must make sure that an exception is raised when an
      # unsupported setting is requested by the caller. Otherwise this can lead
      # to bugs difficult to find. The interface of this function must stay
661
662
      # exactly the same for version 0 and 1 (e.g. they aren't allowed to
      # require different data types).
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731

      # Validate disks
      for idx, disk in enumerate(disks):
        unsupported = set(disk.keys()) - _INST_CREATE_V0_DISK_PARAMS
        if unsupported:
          raise GanetiApiError("Server supports request version 0 only, but"
                               " disk %s specifies the unsupported parameters"
                               " %s, allowed are %s" %
                               (idx, unsupported,
                                list(_INST_CREATE_V0_DISK_PARAMS)))

      assert (len(_INST_CREATE_V0_DISK_PARAMS) == 1 and
              "size" in _INST_CREATE_V0_DISK_PARAMS)
      disk_sizes = [disk["size"] for disk in disks]

      # Validate NICs
      if not nics:
        raise GanetiApiError("Server supports request version 0 only, but"
                             " no NIC specified")
      elif len(nics) > 1:
        raise GanetiApiError("Server supports request version 0 only, but"
                             " more than one NIC specified")

      assert len(nics) == 1

      unsupported = set(nics[0].keys()) - _INST_NIC_PARAMS
      if unsupported:
        raise GanetiApiError("Server supports request version 0 only, but"
                             " NIC 0 specifies the unsupported parameters %s,"
                             " allowed are %s" %
                             (unsupported, list(_INST_NIC_PARAMS)))

      # Validate other parameters
      unsupported = (set(kwargs.keys()) - _INST_CREATE_V0_PARAMS -
                     _INST_CREATE_V0_DPARAMS)
      if unsupported:
        allowed = _INST_CREATE_V0_PARAMS.union(_INST_CREATE_V0_DPARAMS)
        raise GanetiApiError("Server supports request version 0 only, but"
                             " the following unsupported parameters are"
                             " specified: %s, allowed are %s" %
                             (unsupported, list(allowed)))

      # All required fields for request data version 0
      body = {
        _REQ_DATA_VERSION_FIELD: 0,
        "name": name,
        "disk_template": disk_template,
        "disks": disk_sizes,
        }

      # NIC fields
      assert len(nics) == 1
      assert not (set(body.keys()) & set(nics[0].keys()))
      body.update(nics[0])

      # Copy supported fields
      assert not (set(body.keys()) & set(kwargs.keys()))
      body.update(dict((key, value) for key, value in kwargs.items()
                       if key in _INST_CREATE_V0_PARAMS))

      # Merge dictionaries
      for i in (value for key, value in kwargs.items()
                if key in _INST_CREATE_V0_DPARAMS):
        assert not (set(body.keys()) & set(i.keys()))
        body.update(i)

      assert not (set(kwargs.keys()) -
                  (_INST_CREATE_V0_PARAMS | _INST_CREATE_V0_DPARAMS))
      assert not (set(body.keys()) & _INST_CREATE_V0_DPARAMS)
732

733
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
734
                             query, body)
David Knowles's avatar
David Knowles committed
735
736
737
738
739
740
741

  def DeleteInstance(self, instance, dry_run=False):
    """Deletes an instance.

    @type instance: str
    @param instance: the instance to delete

742
743
744
    @rtype: int
    @return: job id

David Knowles's avatar
David Knowles committed
745
746
747
748
749
    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

750
751
752
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
753

754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
  def ModifyInstance(self, instance, **kwargs):
    """Modifies an instance.

    More details for parameters can be found in the RAPI documentation.

    @type instance: string
    @param instance: Instance name
    @rtype: int
    @return: job id

    """
    body = kwargs

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/modify" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
  def ActivateInstanceDisks(self, instance, ignore_size=None):
    """Activates an instance's disks.

    @type instance: string
    @param instance: Instance name
    @type ignore_size: bool
    @param ignore_size: Whether to ignore recorded size
    @return: job id

    """
    query = []
    if ignore_size:
      query.append(("ignore_size", 1))

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/activate-disks" %
                              (GANETI_RAPI_VERSION, instance)), query, None)

  def DeactivateInstanceDisks(self, instance):
    """Deactivates an instance's disks.

    @type instance: string
    @param instance: Instance name
    @return: job id

    """
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/deactivate-disks" %
                              (GANETI_RAPI_VERSION, instance)), None, None)

801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
  def GrowInstanceDisk(self, instance, disk, amount, wait_for_sync=None):
    """Grows a disk of an instance.

    More details for parameters can be found in the RAPI documentation.

    @type instance: string
    @param instance: Instance name
    @type disk: integer
    @param disk: Disk index
    @type amount: integer
    @param amount: Grow disk by this amount (MiB)
    @type wait_for_sync: bool
    @param wait_for_sync: Wait for disk to synchronize
    @rtype: int
    @return: job id

    """
    body = {
      "amount": amount,
      }

    if wait_for_sync is not None:
      body["wait_for_sync"] = wait_for_sync

    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/disk/%s/grow" %
                              (GANETI_RAPI_VERSION, instance, disk)),
                             None, body)

David Knowles's avatar
David Knowles committed
830
831
832
833
834
835
836
837
838
839
  def GetInstanceTags(self, instance):
    """Gets tags for an instance.

    @type instance: str
    @param instance: instance whose tags to return

    @rtype: list of str
    @return: tags for the instance

    """
840
841
842
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), None, None)
David Knowles's avatar
David Knowles committed
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861

  def AddInstanceTags(self, instance, tags, dry_run=False):
    """Adds tags to an instance.

    @type instance: str
    @param instance: instance to add tags to
    @type tags: list of str
    @param tags: tags to add to the instance
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

862
863
864
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880

  def DeleteInstanceTags(self, instance, tags, dry_run=False):
    """Deletes tags from an instance.

    @type instance: str
    @param instance: instance to delete tags from
    @type tags: list of str
    @param tags: tags to delete
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

881
882
883
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907

  def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
                     dry_run=False):
    """Reboots an instance.

    @type instance: str
    @param instance: instance to rebot
    @type reboot_type: str
    @param reboot_type: one of: hard, soft, full
    @type ignore_secondaries: bool
    @param ignore_secondaries: if True, ignores errors for the secondary node
        while re-assembling disks (in hard-reboot mode only)
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if reboot_type:
      query.append(("type", reboot_type))
    if ignore_secondaries is not None:
      query.append(("ignore_secondaries", ignore_secondaries))
    if dry_run:
      query.append(("dry-run", 1))

908
909
910
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reboot" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
911
912
913
914
915
916
917
918
919
920
921
922
923
924

  def ShutdownInstance(self, instance, dry_run=False):
    """Shuts down an instance.

    @type instance: str
    @param instance: the instance to shut down
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

925
926
927
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/shutdown" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
928
929
930
931
932
933
934
935
936
937
938
939
940
941

  def StartupInstance(self, instance, dry_run=False):
    """Starts up an instance.

    @type instance: str
    @param instance: the instance to start up
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

942
943
944
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/startup" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
945

946
947
  def ReinstallInstance(self, instance, os=None, no_startup=False,
                        osparams=None):
David Knowles's avatar
David Knowles committed
948
949
950
    """Reinstalls an instance.

    @type instance: str
951
952
953
954
    @param instance: The instance to reinstall
    @type os: str or None
    @param os: The operating system to reinstall. If None, the instance's
        current operating system will be installed again
David Knowles's avatar
David Knowles committed
955
    @type no_startup: bool
956
    @param no_startup: Whether to start the instance automatically
David Knowles's avatar
David Knowles committed
957
958

    """
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
    if _INST_REINSTALL_REQV1 in self.GetFeatures():
      body = {
        "start": not no_startup,
        }
      if os is not None:
        body["os"] = os
      if osparams is not None:
        body["osparams"] = osparams
      return self._SendRequest(HTTP_POST,
                               ("/%s/instances/%s/reinstall" %
                                (GANETI_RAPI_VERSION, instance)), None, body)

    # Use old request format
    if osparams:
      raise GanetiApiError("Server does not support specifying OS parameters"
                           " for instance reinstallation")

976
977
978
    query = []
    if os:
      query.append(("os", os))
David Knowles's avatar
David Knowles committed
979
980
    if no_startup:
      query.append(("nostartup", 1))
981
982
983
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reinstall" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
984

985
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
986
                           remote_node=None, iallocator=None, dry_run=False):
David Knowles's avatar
David Knowles committed
987
988
989
990
    """Replaces disks on an instance.

    @type instance: str
    @param instance: instance whose disks to replace
991
992
    @type disks: list of ints
    @param disks: Indexes of disks to replace
David Knowles's avatar
David Knowles committed
993
    @type mode: str
994
    @param mode: replacement mode to use (defaults to replace_auto)
David Knowles's avatar
David Knowles committed
995
996
    @type remote_node: str or None
    @param remote_node: new secondary node to use (for use with
997
        replace_new_secondary mode)
David Knowles's avatar
David Knowles committed
998
999
    @type iallocator: str or None
    @param iallocator: instance allocator plugin to use (for use with
1000
                       replace_auto mode)
David Knowles's avatar
David Knowles committed
1001
1002
1003
1004
1005
1006
1007
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
1008
1009
1010
    query = [
      ("mode", mode),
      ]
David Knowles's avatar
David Knowles committed
1011

1012
1013
1014
1015
    if disks:
      query.append(("disks", ",".join(str(idx) for idx in disks)))

    if remote_node:
David Knowles's avatar
David Knowles committed
1016
1017
      query.append(("remote_node", remote_node))

1018
1019
1020
    if iallocator:
      query.append(("iallocator", iallocator))

David Knowles's avatar
David Knowles committed
1021
1022
1023
1024
    if dry_run:
      query.append(("dry-run", 1))

    return self._SendRequest(HTTP_POST,
1025
1026
                             ("/%s/instances/%s/replace-disks" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
1027

1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
  def PrepareExport(self, instance, mode):
    """Prepares an instance for an export.

    @type instance: string
    @param instance: Instance name
    @type mode: string
    @param mode: Export mode
    @rtype: string
    @return: Job ID

    """
    query = [("mode", mode)]
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/prepare-export" %
                              (GANETI_RAPI_VERSION, instance)), query, None)

  def ExportInstance(self, instance, mode, destination, shutdown=None,
                     remove_instance=None,
                     x509_key_name=None, destination_x509_ca=None):
    """Exports an instance.

    @type instance: string
    @param instance: Instance name
    @type mode: string
    @param mode: Export mode
    @rtype: string
    @return: Job ID

    """
    body = {
      "destination": destination,
      "mode": mode,
      }

    if shutdown is not None:
      body["shutdown"] = shutdown

    if remove_instance is not None:
      body["remove_instance"] = remove_instance

    if x509_key_name is not None:
      body["x509_key_name"] = x509_key_name

    if destination_x509_ca is not None:
      body["destination_x509_ca"] = destination_x509_ca

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/export" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

1078
  def MigrateInstance(self, instance, mode=None, cleanup=None):
1079
    """Migrates an instance.
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100

    @type instance: string
    @param instance: Instance name
    @type mode: string
    @param mode: Migration mode
    @type cleanup: bool
    @param cleanup: Whether to clean up a previously failed migration

    """
    body = {}

    if mode is not None:
      body["mode"] = mode

    if cleanup is not None:
      body["cleanup"] = cleanup

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/migrate" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
  def RenameInstance(self, instance, new_name, ip_check=None, name_check=None):
    """Changes the name of an instance.

    @type instance: string
    @param instance: Instance name
    @type new_name: string
    @param new_name: New instance name
    @type ip_check: bool
    @param ip_check: Whether to ensure instance's IP address is inactive
    @type name_check: bool
    @param name_check: Whether to ensure instance's name is resolvable

    """
    body = {
      "new_name": new_name,
      }

    if ip_check is not None:
      body["ip_check"] = ip_check

    if name_check is not None:
      body["name_check"] = name_check

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/rename" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
  def GetInstanceConsole(self, instance):
    """Request information for connecting to instance's console.

    @type instance: string
    @param instance: Instance name

    """
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/console" %
                              (GANETI_RAPI_VERSION, instance)), None, None)

David Knowles's avatar
David Knowles committed
1139
1140
1141
1142
1143
1144
1145
  def GetJobs(self):
    """Gets all jobs for the cluster.

    @rtype: list of int
    @return: job ids for the cluster

    """
1146
    return [int(j["id"])
1147
1148
1149
            for j in self._SendRequest(HTTP_GET,
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
                                       None, None)]
David Knowles's avatar
David Knowles committed
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160

  def GetJobStatus(self, job_id):
    """Gets the status of a job.

    @type job_id: int
    @param job_id: job id whose status to query

    @rtype: dict
    @return: job status

    """
1161
1162
1163
    return self._SendRequest(HTTP_GET,
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
                             None, None)
David Knowles's avatar
David Knowles committed
1164

1165
1166
1167
  def WaitForJobCompletion(self, job_id, period=5, retries=-1):
    """Polls cluster for job status until completion.

1168
1169
    Completion is defined as any of the following states: "error",
    "canceled", or "success".
1170

1171
    @type job_id: string
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
    @param job_id: job id to watch

    @type period: int
    @param period: how often to poll for status (optional, default 5s)

    @type retries: int
    @param retries: how many time to poll before giving up
                    (optional, default -1 means unlimited)

    @rtype: bool
    @return: True if job succeeded or False if failed/status timeout
1183

1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
    """
    while retries != 0:
      job_result = self.GetJobStatus(job_id)
      if not job_result or job_result["status"] in ("error", "canceled"):
        return False
      if job_result["status"] == "success":
        return True
      time.sleep(period)
      if retries > 0:
        retries -= 1
    return False

1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
    """Waits for job changes.

    @type job_id: int
    @param job_id: Job ID for which to wait

    """
    body = {
      "fields": fields,
      "previous_job_info": prev_job_info,
      "previous_log_serial": prev_log_serial,
      }

1209
1210
1211
    return self._SendRequest(HTTP_GET,
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
                             None, body)
1212

1213
1214
  def CancelJob(self, job_id, dry_run=False):
    """Cancels a job.
David Knowles's avatar
David Knowles committed
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225

    @type job_id: int
    @param job_id: id of the job to delete
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

1226
1227
1228
    return self._SendRequest(HTTP_DELETE,
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
                             query, None)
David Knowles's avatar
David Knowles committed
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244

  def GetNodes(self, bulk=False):
    """Gets all nodes in the cluster.

    @type bulk: bool
    @param bulk: whether to return all information about all instances

    @rtype: list of dict or str
    @return: if bulk is true, info about nodes in the cluster,
        else list of nodes in the cluster

    """
    query = []
    if bulk:
      query.append(("bulk", 1))

1245
1246
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
                              query, None)
David Knowles's avatar
David Knowles committed
1247
1248
1249
1250
1251
    if bulk:
      return nodes
    else:
      return [n["id"] for n in nodes]

1252
  def GetNode(self, node):
David Knowles's avatar
David Knowles committed
1253
1254
1255
1256
1257
1258
1259
1260
1261
    """Gets information about a node.

    @type node: str
    @param node: node whose info to return

    @rtype: dict
    @return: info about the node

    """
1262
1263
1264
    return self._SendRequest(HTTP_GET,
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
                             None, None)
David Knowles's avatar
David Knowles committed
1265
1266

  def EvacuateNode(self, node, iallocator=None, remote_node=None,
1267
                   dry_run=False, early_release=False):
David Knowles's avatar
David Knowles committed
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
    """Evacuates instances from a Ganeti node.

    @type node: str
    @param node: node to evacuate
    @type iallocator: str or None
    @param iallocator: instance allocator to use
    @type remote_node: str
    @param remote_node: node to evaucate to
    @type dry_run: bool
    @param dry_run: whether to perform a dry run
1278
1279
    @type early_release: bool
    @param early_release: whether to enable parallelization
David Knowles's avatar
David Knowles committed
1280

1281
1282
1283
1284
    @rtype: list
    @return: list of (job ID, instance name, new secondary node); if
        dry_run was specified, then the actual move jobs were not
        submitted and the job IDs will be C{None}
David Knowles's avatar
David Knowles committed
1285

1286
1287
    @raises GanetiApiError: if an iallocator and remote_node are both
        specified
David Knowles's avatar
David Knowles committed
1288
1289
1290

    """
    if iallocator and remote_node:
1291
      raise GanetiApiError("Only one of iallocator or remote_node can be used")
David Knowles's avatar
David Knowles committed
1292

1293
    query = []
David Knowles's avatar
David Knowles committed
1294
1295
1296
1297
1298
1299
    if iallocator:
      query.append(("iallocator", iallocator))
    if remote_node:
      query.append(("remote_node", remote_node))
    if dry_run:
      query.append(("dry-run", 1))
1300
1301
    if early_release:
      query.append(("early_release", 1))
David Knowles's avatar
David Knowles committed
1302

1303
1304
1305
    return self._SendRequest(HTTP_POST,
                             ("/%s/nodes/%s/evacuate" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1306

1307
  def MigrateNode(self, node, mode=None, dry_run=False):
David Knowles's avatar
David Knowles committed
1308
1309
1310
1311
    """Migrates all primary instances from a node.

    @type node: str
    @param node: node to migrate
1312
1313
1314
    @type mode: string
    @param mode: if passed, it will overwrite the live migration type,
        otherwise the hypervisor default will be used
David Knowles's avatar
David Knowles committed
1315
1316
1317
1318
1319
1320
1321
1322
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = []
1323
1324
    if mode is not None:
      query.append(("mode", mode))
David Knowles's avatar
David Knowles committed
1325
1326
1327
    if dry_run:
      query.append(("dry-run", 1))

1328
1329
1330
    return self._SendRequest(HTTP_POST,
                             ("/%s/nodes/%s/migrate" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341

  def GetNodeRole(self, node):
    """Gets the current role for a node.

    @type node: str
    @param node: node whose role to return

    @rtype: str
    @return: the current role for a node

    """
1342
1343
1344
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/role" %
                              (GANETI_RAPI_VERSION, node)), None, None)
David Knowles's avatar
David Knowles committed
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359

  def SetNodeRole(self, node, role, force=False):
    """Sets the role for a node.

    @type node: str
    @param node: the node whose role to set
    @type role: str
    @param role: the role to set for the node
    @type force: bool
    @param force: whether to force the role change

    @rtype: int
    @return: job id

    """
1360
1361
1362
    query = [
      ("force", force),
      ]
1363

1364
1365
1366
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/role" %
                              (GANETI_RAPI_VERSION, node)), query, role)
David Knowles's avatar
David Knowles committed
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381

  def GetNodeStorageUnits(self, node, storage_type, output_fields):
    """Gets the storage units for a node.

    @type node: str
    @param node: the node whose storage units to return
    @type storage_type: str
    @param storage_type: storage type whose units to return
    @type output_fields: str
    @param output_fields: storage type fields to return

    @rtype: int
    @return: job id where results can be retrieved

    """
1382
1383
1384
1385
    query = [
      ("storage_type", storage_type),
      ("output_fields", output_fields),
      ]
David Knowles's avatar
David Knowles committed
1386

1387
1388
1389
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/storage" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1390

1391
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
David Knowles's avatar
David Knowles committed
1392
1393
1394
1395
1396
1397
1398
1399
    """Modifies parameters of storage units on the node.

    @type node: str
    @param node: node whose storage units to modify
    @type storage_type: str
    @param storage_type: storage type whose units to modify
    @type name: str
    @param name: name of the storage unit
1400
1401
1402
    @type allocatable: bool or None
    @param allocatable: Whether to set the "allocatable" flag on the storage
                        unit (None=no modification, True=set, False=unset)
David Knowles's avatar
David Knowles committed
1403
1404
1405
1406
1407
1408

    @rtype: int
    @return: job id

    """
    query = [
1409
1410
1411
1412
      ("storage_type", storage_type),
      ("name", name),
      ]

1413
1414
1415
    if allocatable is not None:
      query.append(("allocatable", allocatable))

1416
1417
1418
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/storage/modify" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433

  def RepairNodeStorageUnits(self, node, storage_type, name):
    """Repairs a storage unit on the node.

    @type node: str
    @param node: node whose storage units to repair
    @type storage_type: str
    @param storage_type: storage type to repair
    @type name: str
    @param name: name of the storage unit to repair

    @rtype: int
    @return: job id

    """
1434
1435
1436
1437
    query = [
      ("storage_type", storage_type),
      ("name", name),
      ]
David Knowles's avatar
David Knowles committed
1438

1439
1440
1441
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/storage/repair" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452

  def GetNodeTags(self, node):
    """Gets the tags for a node.

    @type node: str
    @param node: node whose tags to return

    @rtype: list of str
    @return: tags for the node

    """
1453
1454
1455
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), None, None)
David Knowles's avatar
David Knowles committed
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474

  def AddNodeTags(self, node, tags, dry_run=False):
    """Adds tags to a node.

    @type node: str
    @param node: node to add tags to
    @type tags: list of str
    @param tags: tags to add to the node
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

1475
1476
1477
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), query, tags)
David Knowles's avatar
David Knowles committed
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496

  def DeleteNodeTags(self, node, tags, dry_run=False):
    """Delete tags from a node.

    @type node: str
    @param node: node to remove tags from
    @type tags: list of str
    @param tags: tags to remove from the node
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

1497
1498
1499
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), query, None)
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536

  def GetGroups(self, bulk=False):
    """Gets all node groups in the cluster.

    @type bulk: bool
    @param bulk: whether to return all information about the groups

    @rtype: list of dict or str
    @return: if bulk is true, a list of dictionaries with info about all node
        groups in the cluster, else a list of names of those node groups

    """
    query = []
    if bulk:
      query.append(("bulk", 1))

    groups = self._SendRequest(HTTP_GET, "/%s/groups" % GANETI_RAPI_VERSION,
                               query, None)
    if bulk:
      return groups
    else:
      return [g["name"] for g in groups]

  def GetGroup(self, group):
    """Gets information about a node group.

    @type group: str
    @param group: name of the node group whose info to return

    @rtype: dict
    @return: info about the node group

    """
    return self._SendRequest(HTTP_GET,
                             "/%s/groups/%s" % (GANETI_RAPI_VERSION, group),
                             None, None)

1537
  def CreateGroup(self, name, alloc_policy=None, dry_run=False):
1538
1539
1540
1541
    """Creates a new node group.

    @type name: str
    @param name: the name of node group to create
1542
1543
    @type alloc_policy: str
    @param alloc_policy: the desired allocation policy for the group, if any
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
    @type dry_run: bool
    @param dry_run: whether to peform a dry run

    @rtype: int
    @return: job id

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

    body = {
      "name": name,
1557
      "alloc_policy": alloc_policy
1558
1559
1560
1561
1562
      }

    return self._SendRequest(HTTP_POST, "/%s/groups" % GANETI_RAPI_VERSION,
                             query, body)

1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
  def ModifyGroup(self, group, **kwargs):
    """Modifies a node group.

    More details for parameters can be found in the RAPI documentation.

    @type group: string
    @param group: Node group name
    @rtype: int
    @return: job id

    """
    return self._SendRequest(HTTP_PUT,
                             ("/%s/groups/%s/modify" %
                              (GANETI_RAPI_VERSION, group)), None, kwargs)

1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
  def DeleteGroup(self, group, dry_run=False):
    """Deletes a node group.

    @type group: str
    @param group: the node group to delete
    @type dry_run: bool
    @param dry_run: whether to peform a dry run

    @rtype: int
    @return: job id

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

    return self._SendRequest(HTTP_DELETE,
                             ("/%s/groups/%s" %
                              (GANETI_RAPI_VERSION, group)), query, None)

  def RenameGroup(self, group, new_name):
    """Changes the name of a node group.

    @type group: string
    @param group: Node group name
    @type new_name: string
    @param new_name: New node group name

    @rtype: int
    @return: job id

    """
    body = {
      "new_name": new_name,
      }

    return self._SendRequest(HTTP_PUT,
                             ("/%s/groups/%s/rename" %
                              (GANETI_RAPI_VERSION, group)), None, body)
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644

  def AssignGroupNodes(self, group, nodes