Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ 2a7c3583

History | View | Annotate | Download (33.8 kB)

1
#
2
#
3

    
4
# Copyright (C) 2010 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
"""Ganeti RAPI client.
23

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

31
"""
32

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

    
36
import logging
37
import simplejson
38
import urllib
39
import threading
40
import pycurl
41

    
42
try:
43
  from cStringIO import StringIO
44
except ImportError:
45
  from StringIO import StringIO
46

    
47

    
48
GANETI_RAPI_PORT = 5080
49
GANETI_RAPI_VERSION = 2
50

    
51
HTTP_DELETE = "DELETE"
52
HTTP_GET = "GET"
53
HTTP_PUT = "PUT"
54
HTTP_POST = "POST"
55
HTTP_OK = 200
56
HTTP_NOT_FOUND = 404
57
HTTP_APP_JSON = "application/json"
58

    
59
REPLACE_DISK_PRI = "replace_on_primary"
60
REPLACE_DISK_SECONDARY = "replace_on_secondary"
61
REPLACE_DISK_CHG = "replace_new_secondary"
62
REPLACE_DISK_AUTO = "replace_auto"
63

    
64
NODE_ROLE_DRAINED = "drained"
65
NODE_ROLE_MASTER_CANDIATE = "master-candidate"
66
NODE_ROLE_MASTER = "master"
67
NODE_ROLE_OFFLINE = "offline"
68
NODE_ROLE_REGULAR = "regular"
69

    
70
# Internal constants
71
_REQ_DATA_VERSION_FIELD = "__version__"
72
_INST_CREATE_REQV1 = "instance-create-reqv1"
73

    
74
# Older pycURL versions don't have all error constants
75
try:
76
  _CURLE_SSL_CACERT = pycurl.E_SSL_CACERT
77
  _CURLE_SSL_CACERT_BADFILE = pycurl.E_SSL_CACERT_BADFILE
78
except AttributeError:
79
  _CURLE_SSL_CACERT = 60
80
  _CURLE_SSL_CACERT_BADFILE = 77
81

    
82
_CURL_SSL_CERT_ERRORS = frozenset([
83
  _CURLE_SSL_CACERT,
84
  _CURLE_SSL_CACERT_BADFILE,
85
  ])
86

    
87

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

91
  """
92
  pass
93

    
94

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

98
  """
99
  pass
100

    
101

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

105
  """
106
  def __init__(self, msg, code=None):
107
    Error.__init__(self, msg)
108
    self.code = code
109

    
110

    
111
def UsesRapiClient(fn):
112
  """Decorator for code using RAPI client to initialize pycURL.
113

114
  """
115
  def wrapper(*args, **kwargs):
116
    # curl_global_init(3) and curl_global_cleanup(3) must be called with only
117
    # one thread running. This check is just a safety measure -- it doesn't
118
    # cover all cases.
119
    assert threading.activeCount() == 1, \
120
           "Found active threads when initializing pycURL"
121

    
122
    pycurl.global_init(pycurl.GLOBAL_ALL)
123
    try:
124
      return fn(*args, **kwargs)
125
    finally:
126
      pycurl.global_cleanup()
127

    
128
  return wrapper
129

    
130

    
131
def GenericCurlConfig(verbose=False, use_signal=False,
132
                      use_curl_cabundle=False, cafile=None, capath=None,
133
                      proxy=None, verify_hostname=False,
134
                      connect_timeout=None, timeout=None,
135
                      _pycurl_version_fn=pycurl.version_info):
136
  """Curl configuration function generator.
137

138
  @type verbose: bool
139
  @param verbose: Whether to set cURL to verbose mode
140
  @type use_signal: bool
141
  @param use_signal: Whether to allow cURL to use signals
142
  @type use_curl_cabundle: bool
143
  @param use_curl_cabundle: Whether to use cURL's default CA bundle
144
  @type cafile: string
145
  @param cafile: In which file we can find the certificates
146
  @type capath: string
147
  @param capath: In which directory we can find the certificates
148
  @type proxy: string
149
  @param proxy: Proxy to use, None for default behaviour and empty string for
150
                disabling proxies (see curl_easy_setopt(3))
151
  @type verify_hostname: bool
152
  @param verify_hostname: Whether to verify the remote peer certificate's
153
                          commonName
154
  @type connect_timeout: number
155
  @param connect_timeout: Timeout for establishing connection in seconds
156
  @type timeout: number
157
  @param timeout: Timeout for complete transfer in seconds (see
158
                  curl_easy_setopt(3)).
159

160
  """
161
  if use_curl_cabundle and (cafile or capath):
162
    raise Error("Can not use default CA bundle when CA file or path is set")
163

    
164
  def _ConfigCurl(curl, logger):
165
    """Configures a cURL object
166

167
    @type curl: pycurl.Curl
168
    @param curl: cURL object
169

170
    """
171
    logger.debug("Using cURL version %s", pycurl.version)
172

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

    
181
    lcsslver = sslver.lower()
182
    if lcsslver.startswith("openssl/"):
183
      pass
184
    elif lcsslver.startswith("gnutls/"):
185
      if capath:
186
        raise Error("cURL linked against GnuTLS has no support for a"
187
                    " CA path (%s)" % (pycurl.version, ))
188
    else:
189
      raise NotImplementedError("cURL uses unsupported SSL version '%s'" %
190
                                sslver)
191

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

    
195
    # Whether to verify remote peer's CN
196
    if verify_hostname:
197
      # curl_easy_setopt(3): "When CURLOPT_SSL_VERIFYHOST is 2, that
198
      # certificate must indicate that the server is the server to which you
199
      # meant to connect, or the connection fails. [...] When the value is 1,
200
      # the certificate must contain a Common Name field, but it doesn't matter
201
      # what name it says. [...]"
202
      curl.setopt(pycurl.SSL_VERIFYHOST, 2)
203
    else:
204
      curl.setopt(pycurl.SSL_VERIFYHOST, 0)
205

    
206
    if cafile or capath or use_curl_cabundle:
207
      # Require certificates to be checked
208
      curl.setopt(pycurl.SSL_VERIFYPEER, True)
209
      if cafile:
210
        curl.setopt(pycurl.CAINFO, str(cafile))
211
      if capath:
212
        curl.setopt(pycurl.CAPATH, str(capath))
213
      # Not changing anything for using default CA bundle
214
    else:
215
      # Disable SSL certificate verification
216
      curl.setopt(pycurl.SSL_VERIFYPEER, False)
217

    
218
    if proxy is not None:
219
      curl.setopt(pycurl.PROXY, str(proxy))
220

    
221
    # Timeouts
222
    if connect_timeout is not None:
223
      curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
224
    if timeout is not None:
225
      curl.setopt(pycurl.TIMEOUT, timeout)
226

    
227
  return _ConfigCurl
228

    
229

    
230
class GanetiRapiClient(object):
231
  """Ganeti RAPI client.
232

233
  """
234
  USER_AGENT = "Ganeti RAPI Client"
235
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
236

    
237
  def __init__(self, host, port=GANETI_RAPI_PORT,
238
               username=None, password=None, logger=logging,
239
               curl_config_fn=None, curl=None):
240
    """Initializes this class.
241

242
    @type host: string
243
    @param host: the ganeti cluster master to interact with
244
    @type port: int
245
    @param port: the port on which the RAPI is running (default is 5080)
246
    @type username: string
247
    @param username: the username to connect with
248
    @type password: string
249
    @param password: the password to connect with
250
    @type curl_config_fn: callable
251
    @param curl_config_fn: Function to configure C{pycurl.Curl} object
252
    @param logger: Logging object
253

254
    """
255
    self._host = host
256
    self._port = port
257
    self._logger = logger
258

    
259
    self._base_url = "https://%s:%s" % (host, port)
260

    
261
    # Create pycURL object if not supplied
262
    if not curl:
263
      curl = pycurl.Curl()
264

    
265
    # Default cURL settings
266
    curl.setopt(pycurl.VERBOSE, False)
267
    curl.setopt(pycurl.FOLLOWLOCATION, False)
268
    curl.setopt(pycurl.MAXREDIRS, 5)
269
    curl.setopt(pycurl.NOSIGNAL, True)
270
    curl.setopt(pycurl.USERAGENT, self.USER_AGENT)
271
    curl.setopt(pycurl.SSL_VERIFYHOST, 0)
272
    curl.setopt(pycurl.SSL_VERIFYPEER, False)
273
    curl.setopt(pycurl.HTTPHEADER, [
274
      "Accept: %s" % HTTP_APP_JSON,
275
      "Content-type: %s" % HTTP_APP_JSON,
276
      ])
277

    
278
    # Setup authentication
279
    if username is not None:
280
      if password is None:
281
        raise Error("Password not specified")
282
      curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
283
      curl.setopt(pycurl.USERPWD, str("%s:%s" % (username, password)))
284
    elif password:
285
      raise Error("Specified password without username")
286

    
287
    # Call external configuration function
288
    if curl_config_fn:
289
      curl_config_fn(curl, logger)
290

    
291
    self._curl = curl
292

    
293
  @staticmethod
294
  def _EncodeQuery(query):
295
    """Encode query values for RAPI URL.
296

297
    @type query: list of two-tuples
298
    @param query: Query arguments
299
    @rtype: list
300
    @return: Query list with encoded values
301

302
    """
303
    result = []
304

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

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

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

    
316
      else:
317
        result.append((name, value))
318

    
319
    return result
320

    
321
  def _SendRequest(self, method, path, query, content):
322
    """Sends an HTTP request.
323

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

327
    @type method: string
328
    @param method: HTTP method to use
329
    @type path: string
330
    @param path: HTTP URL path
331
    @type query: list of two-tuples
332
    @param query: query arguments to pass to urllib.urlencode
333
    @type content: str or None
334
    @param content: HTTP body content
335

336
    @rtype: str
337
    @return: JSON-Decoded response
338

339
    @raises CertificateError: If an invalid SSL certificate is found
340
    @raises GanetiApiError: If an invalid response is returned
341

342
    """
343
    assert path.startswith("/")
344

    
345
    curl = self._curl
346

    
347
    if content:
348
      encoded_content = self._json_encoder.encode(content)
349
    else:
350
      encoded_content = ""
351

    
352
    # Build URL
353
    urlparts = [self._base_url, path]
354
    if query:
355
      urlparts.append("?")
356
      urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
357

    
358
    url = "".join(urlparts)
359

    
360
    self._logger.debug("Sending request %s %s to %s:%s (content=%r)",
361
                       method, url, self._host, self._port, encoded_content)
362

    
363
    # Buffer for response
364
    encoded_resp_body = StringIO()
365

    
366
    # Configure cURL
367
    curl.setopt(pycurl.CUSTOMREQUEST, str(method))
368
    curl.setopt(pycurl.URL, str(url))
369
    curl.setopt(pycurl.POSTFIELDS, str(encoded_content))
370
    curl.setopt(pycurl.WRITEFUNCTION, encoded_resp_body.write)
371

    
372
    try:
373
      # Send request and wait for response
374
      try:
375
        curl.perform()
376
      except pycurl.error, err:
377
        if err.args[0] in _CURL_SSL_CERT_ERRORS:
378
          raise CertificateError("SSL certificate error %s" % err)
379

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

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

    
390
    # Was anything written to the response buffer?
391
    if encoded_resp_body.tell():
392
      response_content = simplejson.loads(encoded_resp_body.getvalue())
393
    else:
394
      response_content = None
395

    
396
    if http_code != HTTP_OK:
397
      if isinstance(response_content, dict):
398
        msg = ("%s %s: %s" %
399
               (response_content["code"],
400
                response_content["message"],
401
                response_content["explain"]))
402
      else:
403
        msg = str(response_content)
404

    
405
      raise GanetiApiError(msg, code=http_code)
406

    
407
    return response_content
408

    
409
  def GetVersion(self):
410
    """Gets the Remote API version running on the cluster.
411

412
    @rtype: int
413
    @return: Ganeti Remote API version
414

415
    """
416
    return self._SendRequest(HTTP_GET, "/version", None, None)
417

    
418
  def GetFeatures(self):
419
    """Gets the list of optional features supported by RAPI server.
420

421
    @rtype: list
422
    @return: List of optional features
423

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

    
433
      raise
434

    
435
  def GetOperatingSystems(self):
436
    """Gets the Operating Systems running in the Ganeti cluster.
437

438
    @rtype: list of str
439
    @return: operating systems
440

441
    """
442
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
443
                             None, None)
444

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

448
    @rtype: dict
449
    @return: information about the cluster
450

451
    """
452
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
453
                             None, None)
454

    
455
  def GetClusterTags(self):
456
    """Gets the cluster tags.
457

458
    @rtype: list of str
459
    @return: cluster tags
460

461
    """
462
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
463
                             None, None)
464

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

468
    @type tags: list of str
469
    @param tags: tags to add to the cluster
470
    @type dry_run: bool
471
    @param dry_run: whether to perform a dry run
472

473
    @rtype: int
474
    @return: job id
475

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

    
481
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
482
                             query, None)
483

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

487
    @type tags: list of str
488
    @param tags: tags to delete
489
    @type dry_run: bool
490
    @param dry_run: whether to perform a dry run
491

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

    
497
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
498
                             query, None)
499

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

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

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

509
    """
510
    query = []
511
    if bulk:
512
      query.append(("bulk", 1))
513

    
514
    instances = self._SendRequest(HTTP_GET,
515
                                  "/%s/instances" % GANETI_RAPI_VERSION,
516
                                  query, None)
517
    if bulk:
518
      return instances
519
    else:
520
      return [i["id"] for i in instances]
521

    
522
  def GetInstance(self, instance):
523
    """Gets information about an instance.
524

525
    @type instance: str
526
    @param instance: instance whose info to return
527

528
    @rtype: dict
529
    @return: info about the instance
530

531
    """
532
    return self._SendRequest(HTTP_GET,
533
                             ("/%s/instances/%s" %
534
                              (GANETI_RAPI_VERSION, instance)), None, None)
535

    
536
  def GetInstanceInfo(self, instance, static=None):
537
    """Gets information about an instance.
538

539
    @type instance: string
540
    @param instance: Instance name
541
    @rtype: string
542
    @return: Job ID
543

544
    """
545
    if static is not None:
546
      query = [("static", static)]
547
    else:
548
      query = None
549

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

    
554
  def CreateInstance(self, mode, name, disk_template, disks, nics,
555
                     **kwargs):
556
    """Creates a new instance.
557

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

560
    @type mode: string
561
    @param mode: Instance creation mode
562
    @type name: string
563
    @param name: Hostname of the instance to create
564
    @type disk_template: string
565
    @param disk_template: Disk template for instance (e.g. plain, diskless,
566
                          file, or drbd)
567
    @type disks: list of dicts
568
    @param disks: List of disk definitions
569
    @type nics: list of dicts
570
    @param nics: List of NIC definitions
571
    @type dry_run: bool
572
    @keyword dry_run: whether to perform a dry run
573

574
    @rtype: int
575
    @return: job id
576

577
    """
578
    query = []
579

    
580
    if kwargs.get("dry_run"):
581
      query.append(("dry-run", 1))
582

    
583
    if _INST_CREATE_REQV1 in self.GetFeatures():
584
      # All required fields for request data version 1
585
      body = {
586
        _REQ_DATA_VERSION_FIELD: 1,
587
        "mode": mode,
588
        "name": name,
589
        "disk_template": disk_template,
590
        "disks": disks,
591
        "nics": nics,
592
        }
593

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

    
599
      body.update((key, value) for key, value in kwargs.iteritems()
600
                  if key != "dry_run")
601
    else:
602
      # TODO: Implement instance creation request data version 0
603
      # When implementing version 0, care should be taken to refuse unknown
604
      # parameters and invalid values. The interface of this function must stay
605
      # exactly the same for version 0 and 1 (e.g. they aren't allowed to
606
      # require different data types).
607
      raise NotImplementedError("Support for instance creation request data"
608
                                " version 0 is not yet implemented")
609

    
610
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
611
                             query, body)
612

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

616
    @type instance: str
617
    @param instance: the instance to delete
618

619
    @rtype: int
620
    @return: job id
621

622
    """
623
    query = []
624
    if dry_run:
625
      query.append(("dry-run", 1))
626

    
627
    return self._SendRequest(HTTP_DELETE,
628
                             ("/%s/instances/%s" %
629
                              (GANETI_RAPI_VERSION, instance)), query, None)
630

    
631
  def GetInstanceTags(self, instance):
632
    """Gets tags for an instance.
633

634
    @type instance: str
635
    @param instance: instance whose tags to return
636

637
    @rtype: list of str
638
    @return: tags for the instance
639

640
    """
641
    return self._SendRequest(HTTP_GET,
642
                             ("/%s/instances/%s/tags" %
643
                              (GANETI_RAPI_VERSION, instance)), None, None)
644

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

648
    @type instance: str
649
    @param instance: instance to add tags to
650
    @type tags: list of str
651
    @param tags: tags to add to the instance
652
    @type dry_run: bool
653
    @param dry_run: whether to perform a dry run
654

655
    @rtype: int
656
    @return: job id
657

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

    
663
    return self._SendRequest(HTTP_PUT,
664
                             ("/%s/instances/%s/tags" %
665
                              (GANETI_RAPI_VERSION, instance)), query, None)
666

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

670
    @type instance: str
671
    @param instance: instance to delete tags from
672
    @type tags: list of str
673
    @param tags: tags to delete
674
    @type dry_run: bool
675
    @param dry_run: whether to perform a dry run
676

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

    
682
    return self._SendRequest(HTTP_DELETE,
683
                             ("/%s/instances/%s/tags" %
684
                              (GANETI_RAPI_VERSION, instance)), query, None)
685

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

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

700
    """
701
    query = []
702
    if reboot_type:
703
      query.append(("type", reboot_type))
704
    if ignore_secondaries is not None:
705
      query.append(("ignore_secondaries", ignore_secondaries))
706
    if dry_run:
707
      query.append(("dry-run", 1))
708

    
709
    return self._SendRequest(HTTP_POST,
710
                             ("/%s/instances/%s/reboot" %
711
                              (GANETI_RAPI_VERSION, instance)), query, None)
712

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

716
    @type instance: str
717
    @param instance: the instance to shut down
718
    @type dry_run: bool
719
    @param dry_run: whether to perform a dry run
720

721
    """
722
    query = []
723
    if dry_run:
724
      query.append(("dry-run", 1))
725

    
726
    return self._SendRequest(HTTP_PUT,
727
                             ("/%s/instances/%s/shutdown" %
728
                              (GANETI_RAPI_VERSION, instance)), query, None)
729

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

733
    @type instance: str
734
    @param instance: the instance to start up
735
    @type dry_run: bool
736
    @param dry_run: whether to perform a dry run
737

738
    """
739
    query = []
740
    if dry_run:
741
      query.append(("dry-run", 1))
742

    
743
    return self._SendRequest(HTTP_PUT,
744
                             ("/%s/instances/%s/startup" %
745
                              (GANETI_RAPI_VERSION, instance)), query, None)
746

    
747
  def ReinstallInstance(self, instance, os, no_startup=False):
748
    """Reinstalls an instance.
749

750
    @type instance: str
751
    @param instance: the instance to reinstall
752
    @type os: str
753
    @param os: the os to reinstall
754
    @type no_startup: bool
755
    @param no_startup: whether to start the instance automatically
756

757
    """
758
    query = [("os", os)]
759
    if no_startup:
760
      query.append(("nostartup", 1))
761
    return self._SendRequest(HTTP_POST,
762
                             ("/%s/instances/%s/reinstall" %
763
                              (GANETI_RAPI_VERSION, instance)), query, None)
764

    
765
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
766
                           remote_node=None, iallocator=None, dry_run=False):
767
    """Replaces disks on an instance.
768

769
    @type instance: str
770
    @param instance: instance whose disks to replace
771
    @type disks: list of ints
772
    @param disks: Indexes of disks to replace
773
    @type mode: str
774
    @param mode: replacement mode to use (defaults to replace_auto)
775
    @type remote_node: str or None
776
    @param remote_node: new secondary node to use (for use with
777
        replace_new_secondary mode)
778
    @type iallocator: str or None
779
    @param iallocator: instance allocator plugin to use (for use with
780
                       replace_auto mode)
781
    @type dry_run: bool
782
    @param dry_run: whether to perform a dry run
783

784
    @rtype: int
785
    @return: job id
786

787
    """
788
    query = [
789
      ("mode", mode),
790
      ]
791

    
792
    if disks:
793
      query.append(("disks", ",".join(str(idx) for idx in disks)))
794

    
795
    if remote_node:
796
      query.append(("remote_node", remote_node))
797

    
798
    if iallocator:
799
      query.append(("iallocator", iallocator))
800

    
801
    if dry_run:
802
      query.append(("dry-run", 1))
803

    
804
    return self._SendRequest(HTTP_POST,
805
                             ("/%s/instances/%s/replace-disks" %
806
                              (GANETI_RAPI_VERSION, instance)), query, None)
807

    
808
  def PrepareExport(self, instance, mode):
809
    """Prepares an instance for an export.
810

811
    @type instance: string
812
    @param instance: Instance name
813
    @type mode: string
814
    @param mode: Export mode
815
    @rtype: string
816
    @return: Job ID
817

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

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

829
    @type instance: string
830
    @param instance: Instance name
831
    @type mode: string
832
    @param mode: Export mode
833
    @rtype: string
834
    @return: Job ID
835

836
    """
837
    body = {
838
      "destination": destination,
839
      "mode": mode,
840
      }
841

    
842
    if shutdown is not None:
843
      body["shutdown"] = shutdown
844

    
845
    if remove_instance is not None:
846
      body["remove_instance"] = remove_instance
847

    
848
    if x509_key_name is not None:
849
      body["x509_key_name"] = x509_key_name
850

    
851
    if destination_x509_ca is not None:
852
      body["destination_x509_ca"] = destination_x509_ca
853

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

    
858
  def GetJobs(self):
859
    """Gets all jobs for the cluster.
860

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

864
    """
865
    return [int(j["id"])
866
            for j in self._SendRequest(HTTP_GET,
867
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
868
                                       None, None)]
869

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

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

876
    @rtype: dict
877
    @return: job status
878

879
    """
880
    return self._SendRequest(HTTP_GET,
881
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
882
                             None, None)
883

    
884
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
885
    """Waits for job changes.
886

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

890
    """
891
    body = {
892
      "fields": fields,
893
      "previous_job_info": prev_job_info,
894
      "previous_log_serial": prev_log_serial,
895
      }
896

    
897
    return self._SendRequest(HTTP_GET,
898
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
899
                             None, body)
900

    
901
  def CancelJob(self, job_id, dry_run=False):
902
    """Cancels a job.
903

904
    @type job_id: int
905
    @param job_id: id of the job to delete
906
    @type dry_run: bool
907
    @param dry_run: whether to perform a dry run
908

909
    """
910
    query = []
911
    if dry_run:
912
      query.append(("dry-run", 1))
913

    
914
    return self._SendRequest(HTTP_DELETE,
915
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
916
                             query, None)
917

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

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

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

928
    """
929
    query = []
930
    if bulk:
931
      query.append(("bulk", 1))
932

    
933
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
934
                              query, None)
935
    if bulk:
936
      return nodes
937
    else:
938
      return [n["id"] for n in nodes]
939

    
940
  def GetNode(self, node):
941
    """Gets information about a node.
942

943
    @type node: str
944
    @param node: node whose info to return
945

946
    @rtype: dict
947
    @return: info about the node
948

949
    """
950
    return self._SendRequest(HTTP_GET,
951
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
952
                             None, None)
953

    
954
  def EvacuateNode(self, node, iallocator=None, remote_node=None,
955
                   dry_run=False, early_release=False):
956
    """Evacuates instances from a Ganeti node.
957

958
    @type node: str
959
    @param node: node to evacuate
960
    @type iallocator: str or None
961
    @param iallocator: instance allocator to use
962
    @type remote_node: str
963
    @param remote_node: node to evaucate to
964
    @type dry_run: bool
965
    @param dry_run: whether to perform a dry run
966
    @type early_release: bool
967
    @param early_release: whether to enable parallelization
968

969
    @rtype: list
970
    @return: list of (job ID, instance name, new secondary node); if
971
        dry_run was specified, then the actual move jobs were not
972
        submitted and the job IDs will be C{None}
973

974
    @raises GanetiApiError: if an iallocator and remote_node are both
975
        specified
976

977
    """
978
    if iallocator and remote_node:
979
      raise GanetiApiError("Only one of iallocator or remote_node can be used")
980

    
981
    query = []
982
    if iallocator:
983
      query.append(("iallocator", iallocator))
984
    if remote_node:
985
      query.append(("remote_node", remote_node))
986
    if dry_run:
987
      query.append(("dry-run", 1))
988
    if early_release:
989
      query.append(("early_release", 1))
990

    
991
    return self._SendRequest(HTTP_POST,
992
                             ("/%s/nodes/%s/evacuate" %
993
                              (GANETI_RAPI_VERSION, node)), query, None)
994

    
995
  def MigrateNode(self, node, live=True, dry_run=False):
996
    """Migrates all primary instances from a node.
997

998
    @type node: str
999
    @param node: node to migrate
1000
    @type live: bool
1001
    @param live: whether to use live migration
1002
    @type dry_run: bool
1003
    @param dry_run: whether to perform a dry run
1004

1005
    @rtype: int
1006
    @return: job id
1007

1008
    """
1009
    query = []
1010
    if live:
1011
      query.append(("live", 1))
1012
    if dry_run:
1013
      query.append(("dry-run", 1))
1014

    
1015
    return self._SendRequest(HTTP_POST,
1016
                             ("/%s/nodes/%s/migrate" %
1017
                              (GANETI_RAPI_VERSION, node)), query, None)
1018

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

1022
    @type node: str
1023
    @param node: node whose role to return
1024

1025
    @rtype: str
1026
    @return: the current role for a node
1027

1028
    """
1029
    return self._SendRequest(HTTP_GET,
1030
                             ("/%s/nodes/%s/role" %
1031
                              (GANETI_RAPI_VERSION, node)), None, None)
1032

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

1036
    @type node: str
1037
    @param node: the node whose role to set
1038
    @type role: str
1039
    @param role: the role to set for the node
1040
    @type force: bool
1041
    @param force: whether to force the role change
1042

1043
    @rtype: int
1044
    @return: job id
1045

1046
    """
1047
    query = [
1048
      ("force", force),
1049
      ]
1050

    
1051
    return self._SendRequest(HTTP_PUT,
1052
                             ("/%s/nodes/%s/role" %
1053
                              (GANETI_RAPI_VERSION, node)), query, role)
1054

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

1058
    @type node: str
1059
    @param node: the node whose storage units to return
1060
    @type storage_type: str
1061
    @param storage_type: storage type whose units to return
1062
    @type output_fields: str
1063
    @param output_fields: storage type fields to return
1064

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

1068
    """
1069
    query = [
1070
      ("storage_type", storage_type),
1071
      ("output_fields", output_fields),
1072
      ]
1073

    
1074
    return self._SendRequest(HTTP_GET,
1075
                             ("/%s/nodes/%s/storage" %
1076
                              (GANETI_RAPI_VERSION, node)), query, None)
1077

    
1078
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
1079
    """Modifies parameters of storage units on the node.
1080

1081
    @type node: str
1082
    @param node: node whose storage units to modify
1083
    @type storage_type: str
1084
    @param storage_type: storage type whose units to modify
1085
    @type name: str
1086
    @param name: name of the storage unit
1087
    @type allocatable: bool or None
1088
    @param allocatable: Whether to set the "allocatable" flag on the storage
1089
                        unit (None=no modification, True=set, False=unset)
1090

1091
    @rtype: int
1092
    @return: job id
1093

1094
    """
1095
    query = [
1096
      ("storage_type", storage_type),
1097
      ("name", name),
1098
      ]
1099

    
1100
    if allocatable is not None:
1101
      query.append(("allocatable", allocatable))
1102

    
1103
    return self._SendRequest(HTTP_PUT,
1104
                             ("/%s/nodes/%s/storage/modify" %
1105
                              (GANETI_RAPI_VERSION, node)), query, None)
1106

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

1110
    @type node: str
1111
    @param node: node whose storage units to repair
1112
    @type storage_type: str
1113
    @param storage_type: storage type to repair
1114
    @type name: str
1115
    @param name: name of the storage unit to repair
1116

1117
    @rtype: int
1118
    @return: job id
1119

1120
    """
1121
    query = [
1122
      ("storage_type", storage_type),
1123
      ("name", name),
1124
      ]
1125

    
1126
    return self._SendRequest(HTTP_PUT,
1127
                             ("/%s/nodes/%s/storage/repair" %
1128
                              (GANETI_RAPI_VERSION, node)), query, None)
1129

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

1133
    @type node: str
1134
    @param node: node whose tags to return
1135

1136
    @rtype: list of str
1137
    @return: tags for the node
1138

1139
    """
1140
    return self._SendRequest(HTTP_GET,
1141
                             ("/%s/nodes/%s/tags" %
1142
                              (GANETI_RAPI_VERSION, node)), None, None)
1143

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

1147
    @type node: str
1148
    @param node: node to add tags to
1149
    @type tags: list of str
1150
    @param tags: tags to add to the node
1151
    @type dry_run: bool
1152
    @param dry_run: whether to perform a dry run
1153

1154
    @rtype: int
1155
    @return: job id
1156

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

    
1162
    return self._SendRequest(HTTP_PUT,
1163
                             ("/%s/nodes/%s/tags" %
1164
                              (GANETI_RAPI_VERSION, node)), query, tags)
1165

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

1169
    @type node: str
1170
    @param node: node to remove tags from
1171
    @type tags: list of str
1172
    @param tags: tags to remove from the node
1173
    @type dry_run: bool
1174
    @param dry_run: whether to perform a dry run
1175

1176
    @rtype: int
1177
    @return: job id
1178

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

    
1184
    return self._SendRequest(HTTP_DELETE,
1185
                             ("/%s/nodes/%s/tags" %
1186
                              (GANETI_RAPI_VERSION, node)), query, None)