Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ 941b9309

History | View | Annotate | Download (35.2 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
# No Ganeti-specific modules should be imported. The RAPI client is supposed to
25
# be standalone.
26

    
27
import sys
28
import httplib
29
import urllib2
30
import logging
31
import simplejson
32
import socket
33
import urllib
34
import OpenSSL
35
import distutils.version
36

    
37

    
38
GANETI_RAPI_PORT = 5080
39
GANETI_RAPI_VERSION = 2
40

    
41
HTTP_DELETE = "DELETE"
42
HTTP_GET = "GET"
43
HTTP_PUT = "PUT"
44
HTTP_POST = "POST"
45
HTTP_OK = 200
46
HTTP_NOT_FOUND = 404
47
HTTP_APP_JSON = "application/json"
48

    
49
REPLACE_DISK_PRI = "replace_on_primary"
50
REPLACE_DISK_SECONDARY = "replace_on_secondary"
51
REPLACE_DISK_CHG = "replace_new_secondary"
52
REPLACE_DISK_AUTO = "replace_auto"
53

    
54
NODE_ROLE_DRAINED = "drained"
55
NODE_ROLE_MASTER_CANDIATE = "master-candidate"
56
NODE_ROLE_MASTER = "master"
57
NODE_ROLE_OFFLINE = "offline"
58
NODE_ROLE_REGULAR = "regular"
59

    
60
# Internal constants
61
_REQ_DATA_VERSION_FIELD = "__version__"
62
_INST_CREATE_REQV1 = "instance-create-reqv1"
63

    
64

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

68
  """
69
  pass
70

    
71

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

75
  """
76
  pass
77

    
78

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

82
  """
83
  def __init__(self, msg, code=None):
84
    Error.__init__(self, msg)
85
    self.code = code
86

    
87

    
88
def FormatX509Name(x509_name):
89
  """Formats an X509 name.
90

91
  @type x509_name: OpenSSL.crypto.X509Name
92

93
  """
94
  try:
95
    # Only supported in pyOpenSSL 0.7 and above
96
    get_components_fn = x509_name.get_components
97
  except AttributeError:
98
    return repr(x509_name)
99
  else:
100
    return "".join("/%s=%s" % (name, value)
101
                   for name, value in get_components_fn())
102

    
103

    
104
class CertAuthorityVerify:
105
  """Certificate verificator for SSL context.
106

107
  Configures SSL context to verify server's certificate.
108

109
  """
110
  _CAPATH_MINVERSION = "0.9"
111
  _DEFVFYPATHS_MINVERSION = "0.9"
112

    
113
  _PYOPENSSL_VERSION = OpenSSL.__version__
114
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
115

    
116
  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
117
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
118

    
119
  def __init__(self, cafile=None, capath=None, use_default_verify_paths=False):
120
    """Initializes this class.
121

122
    @type cafile: string
123
    @param cafile: In which file we can find the certificates
124
    @type capath: string
125
    @param capath: In which directory we can find the certificates
126
    @type use_default_verify_paths: bool
127
    @param use_default_verify_paths: Whether the platform provided CA
128
                                     certificates are to be used for
129
                                     verification purposes
130

131
    """
132
    self._cafile = cafile
133
    self._capath = capath
134
    self._use_default_verify_paths = use_default_verify_paths
135

    
136
    if self._capath is not None and not self._SUPPORT_CAPATH:
137
      raise Error(("PyOpenSSL %s has no support for a CA directory,"
138
                   " version %s or above is required") %
139
                  (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION))
140

    
141
    if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS:
142
      raise Error(("PyOpenSSL %s has no support for using default verification"
143
                   " paths, version %s or above is required") %
144
                  (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION))
145

    
146
  @staticmethod
147
  def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok):
148
    """Callback for SSL certificate verification.
149

150
    @param logger: Logging object
151

152
    """
153
    if ok:
154
      log_fn = logger.debug
155
    else:
156
      log_fn = logger.error
157

    
158
    log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'",
159
           errdepth, FormatX509Name(cert.get_subject()),
160
           FormatX509Name(cert.get_issuer()))
161

    
162
    if not ok:
163
      try:
164
        # Only supported in pyOpenSSL 0.7 and above
165
        # pylint: disable-msg=E1101
166
        fn = OpenSSL.crypto.X509_verify_cert_error_string
167
      except AttributeError:
168
        errmsg = ""
169
      else:
170
        errmsg = ":%s" % fn(errnum)
171

    
172
      logger.error("verify error:num=%s%s", errnum, errmsg)
173

    
174
    return ok
175

    
176
  def __call__(self, ctx, logger):
177
    """Configures an SSL context to verify certificates.
178

179
    @type ctx: OpenSSL.SSL.Context
180
    @param ctx: SSL context
181

182
    """
183
    if self._use_default_verify_paths:
184
      ctx.set_default_verify_paths()
185

    
186
    if self._cafile or self._capath:
187
      if self._SUPPORT_CAPATH:
188
        ctx.load_verify_locations(self._cafile, self._capath)
189
      else:
190
        ctx.load_verify_locations(self._cafile)
191

    
192
    ctx.set_verify(OpenSSL.SSL.VERIFY_PEER,
193
                   lambda conn, cert, errnum, errdepth, ok: \
194
                     self._VerifySslCertCb(logger, conn, cert,
195
                                           errnum, errdepth, ok))
196

    
197

    
198
class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
199
  """HTTPS Connection handler that verifies the SSL certificate.
200

201
  """
202
  # Python before version 2.6 had its own httplib.FakeSocket wrapper for
203
  # sockets
204
  _SUPPORT_FAKESOCKET = (sys.hexversion < 0x2060000)
205

    
206
  def __init__(self, *args, **kwargs):
207
    """Initializes this class.
208

209
    """
210
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
211
    self._logger = None
212
    self._config_ssl_verification = None
213

    
214
  def Setup(self, logger, config_ssl_verification):
215
    """Sets the SSL verification config function.
216

217
    @param logger: Logging object
218
    @type config_ssl_verification: callable
219

220
    """
221
    assert self._logger is None
222
    assert self._config_ssl_verification is None
223

    
224
    self._logger = logger
225
    self._config_ssl_verification = config_ssl_verification
226

    
227
  def connect(self):
228
    """Connect to the server specified when the object was created.
229

230
    This ensures that SSL certificates are verified.
231

232
    """
233
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
234

    
235
    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
236
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
237

    
238
    if self._config_ssl_verification:
239
      self._config_ssl_verification(ctx, self._logger)
240

    
241
    ssl = OpenSSL.SSL.Connection(ctx, sock)
242
    ssl.connect((self.host, self.port))
243

    
244
    if self._SUPPORT_FAKESOCKET:
245
      self.sock = httplib.FakeSocket(sock, ssl)
246
    else:
247
      self.sock = _SslSocketWrapper(ssl)
248

    
249

    
250
class _SslSocketWrapper(object):
251
  def __init__(self, sock):
252
    """Initializes this class.
253

254
    """
255
    self._sock = sock
256

    
257
  def __getattr__(self, name):
258
    """Forward everything to underlying socket.
259

260
    """
261
    return getattr(self._sock, name)
262

    
263
  def makefile(self, mode, bufsize):
264
    """Fake makefile method.
265

266
    makefile() on normal file descriptors uses dup2(2), which doesn't work with
267
    SSL sockets and therefore is not implemented by pyOpenSSL. This fake method
268
    works with the httplib module, but might not work for other modules.
269

270
    """
271
    # pylint: disable-msg=W0212
272
    return socket._fileobject(self._sock, mode, bufsize)
273

    
274

    
275
class _HTTPSHandler(urllib2.HTTPSHandler):
276
  def __init__(self, logger, config_ssl_verification):
277
    """Initializes this class.
278

279
    @param logger: Logging object
280
    @type config_ssl_verification: callable
281
    @param config_ssl_verification: Function to configure SSL context for
282
                                    certificate verification
283

284
    """
285
    urllib2.HTTPSHandler.__init__(self)
286
    self._logger = logger
287
    self._config_ssl_verification = config_ssl_verification
288

    
289
  def _CreateHttpsConnection(self, *args, **kwargs):
290
    """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification.
291

292
    This wrapper is necessary provide a compatible API to urllib2.
293

294
    """
295
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
296
    conn.Setup(self._logger, self._config_ssl_verification)
297
    return conn
298

    
299
  def https_open(self, req):
300
    """Creates HTTPS connection.
301

302
    Called by urllib2.
303

304
    """
305
    return self.do_open(self._CreateHttpsConnection, req)
306

    
307

    
308
class _RapiRequest(urllib2.Request):
309
  def __init__(self, method, url, headers, data):
310
    """Initializes this class.
311

312
    """
313
    urllib2.Request.__init__(self, url, data=data, headers=headers)
314
    self._method = method
315

    
316
  def get_method(self):
317
    """Returns the HTTP request method.
318

319
    """
320
    return self._method
321

    
322

    
323
class GanetiRapiClient(object):
324
  """Ganeti RAPI client.
325

326
  """
327
  USER_AGENT = "Ganeti RAPI Client"
328
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
329

    
330
  def __init__(self, host, port=GANETI_RAPI_PORT,
331
               username=None, password=None,
332
               config_ssl_verification=None, ignore_proxy=False,
333
               logger=logging):
334
    """Constructor.
335

336
    @type host: string
337
    @param host: the ganeti cluster master to interact with
338
    @type port: int
339
    @param port: the port on which the RAPI is running (default is 5080)
340
    @type username: string
341
    @param username: the username to connect with
342
    @type password: string
343
    @param password: the password to connect with
344
    @type config_ssl_verification: callable
345
    @param config_ssl_verification: Function to configure SSL context for
346
                                    certificate verification
347
    @type ignore_proxy: bool
348
    @param ignore_proxy: Whether to ignore proxy settings
349
    @param logger: Logging object
350

351
    """
352
    self._host = host
353
    self._port = port
354
    self._logger = logger
355

    
356
    self._base_url = "https://%s:%s" % (host, port)
357

    
358
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
359

    
360
    if username is not None:
361
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
362
      pwmgr.add_password(None, self._base_url, username, password)
363
      handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
364
    elif password:
365
      raise Error("Specified password without username")
366

    
367
    if ignore_proxy:
368
      handlers.append(urllib2.ProxyHandler({}))
369

    
370
    self._http = urllib2.build_opener(*handlers) # pylint: disable-msg=W0142
371

    
372
    self._headers = {
373
      "Accept": HTTP_APP_JSON,
374
      "Content-type": HTTP_APP_JSON,
375
      "User-Agent": self.USER_AGENT,
376
      }
377

    
378
  @staticmethod
379
  def _EncodeQuery(query):
380
    """Encode query values for RAPI URL.
381

382
    @type query: list of two-tuples
383
    @param query: Query arguments
384
    @rtype: list
385
    @return: Query list with encoded values
386

387
    """
388
    result = []
389

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

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

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

    
401
      else:
402
        result.append((name, value))
403

    
404
    return result
405

    
406
  def _SendRequest(self, method, path, query, content):
407
    """Sends an HTTP request.
408

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

412
    @type method: string
413
    @param method: HTTP method to use
414
    @type path: string
415
    @param path: HTTP URL path
416
    @type query: list of two-tuples
417
    @param query: query arguments to pass to urllib.urlencode
418
    @type content: str or None
419
    @param content: HTTP body content
420

421
    @rtype: str
422
    @return: JSON-Decoded response
423

424
    @raises CertificateError: If an invalid SSL certificate is found
425
    @raises GanetiApiError: If an invalid response is returned
426

427
    """
428
    assert path.startswith("/")
429

    
430
    if content:
431
      encoded_content = self._json_encoder.encode(content)
432
    else:
433
      encoded_content = None
434

    
435
    # Build URL
436
    urlparts = [self._base_url, path]
437
    if query:
438
      urlparts.append("?")
439
      urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
440

    
441
    url = "".join(urlparts)
442

    
443
    self._logger.debug("Sending request %s %s to %s:%s"
444
                       " (headers=%r, content=%r)",
445
                       method, url, self._host, self._port, self._headers,
446
                       encoded_content)
447

    
448
    req = _RapiRequest(method, url, self._headers, encoded_content)
449

    
450
    try:
451
      resp = self._http.open(req)
452
      encoded_response_content = resp.read()
453
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
454
      raise CertificateError("SSL issue: %s (%r)" % (err, err))
455
    except urllib2.HTTPError, err:
456
      raise GanetiApiError(str(err), code=err.code)
457
    except urllib2.URLError, err:
458
      raise GanetiApiError(str(err))
459

    
460
    if encoded_response_content:
461
      response_content = simplejson.loads(encoded_response_content)
462
    else:
463
      response_content = None
464

    
465
    # TODO: Are there other status codes that are valid? (redirect?)
466
    if resp.code != HTTP_OK:
467
      if isinstance(response_content, dict):
468
        msg = ("%s %s: %s" %
469
               (response_content["code"],
470
                response_content["message"],
471
                response_content["explain"]))
472
      else:
473
        msg = str(response_content)
474

    
475
      raise GanetiApiError(msg, code=resp.code)
476

    
477
    return response_content
478

    
479
  def GetVersion(self):
480
    """Gets the Remote API version running on the cluster.
481

482
    @rtype: int
483
    @return: Ganeti Remote API version
484

485
    """
486
    return self._SendRequest(HTTP_GET, "/version", None, None)
487

    
488
  def GetFeatures(self):
489
    """Gets the list of optional features supported by RAPI server.
490

491
    @rtype: list
492
    @return: List of optional features
493

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

    
503
      raise
504

    
505
  def GetOperatingSystems(self):
506
    """Gets the Operating Systems running in the Ganeti cluster.
507

508
    @rtype: list of str
509
    @return: operating systems
510

511
    """
512
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
513
                             None, None)
514

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

518
    @rtype: dict
519
    @return: information about the cluster
520

521
    """
522
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
523
                             None, None)
524

    
525
  def GetClusterTags(self):
526
    """Gets the cluster tags.
527

528
    @rtype: list of str
529
    @return: cluster tags
530

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

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

538
    @type tags: list of str
539
    @param tags: tags to add to the cluster
540
    @type dry_run: bool
541
    @param dry_run: whether to perform a dry run
542

543
    @rtype: int
544
    @return: job id
545

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

    
551
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
552
                             query, None)
553

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

557
    @type tags: list of str
558
    @param tags: tags to delete
559
    @type dry_run: bool
560
    @param dry_run: whether to perform a dry run
561

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

    
567
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
568
                             query, None)
569

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

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

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

579
    """
580
    query = []
581
    if bulk:
582
      query.append(("bulk", 1))
583

    
584
    instances = self._SendRequest(HTTP_GET,
585
                                  "/%s/instances" % GANETI_RAPI_VERSION,
586
                                  query, None)
587
    if bulk:
588
      return instances
589
    else:
590
      return [i["id"] for i in instances]
591

    
592
  def GetInstance(self, instance):
593
    """Gets information about an instance.
594

595
    @type instance: str
596
    @param instance: instance whose info to return
597

598
    @rtype: dict
599
    @return: info about the instance
600

601
    """
602
    return self._SendRequest(HTTP_GET,
603
                             ("/%s/instances/%s" %
604
                              (GANETI_RAPI_VERSION, instance)), None, None)
605

    
606
  def GetInstanceInfo(self, instance, static=None):
607
    """Gets information about an instance.
608

609
    @type instance: string
610
    @param instance: Instance name
611
    @rtype: string
612
    @return: Job ID
613

614
    """
615
    if static is not None:
616
      query = [("static", static)]
617
    else:
618
      query = None
619

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

    
624
  def CreateInstance(self, mode, name, disk_template, disks, nics,
625
                     **kwargs):
626
    """Creates a new instance.
627

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

630
    @type mode: string
631
    @param mode: Instance creation mode
632
    @type name: string
633
    @param name: Hostname of the instance to create
634
    @type disk_template: string
635
    @param disk_template: Disk template for instance (e.g. plain, diskless,
636
                          file, or drbd)
637
    @type disks: list of dicts
638
    @param disks: List of disk definitions
639
    @type nics: list of dicts
640
    @param nics: List of NIC definitions
641
    @type dry_run: bool
642
    @keyword dry_run: whether to perform a dry run
643

644
    @rtype: int
645
    @return: job id
646

647
    """
648
    query = []
649

    
650
    if kwargs.get("dry_run"):
651
      query.append(("dry-run", 1))
652

    
653
    if _INST_CREATE_REQV1 in self.GetFeatures():
654
      # All required fields for request data version 1
655
      body = {
656
        _REQ_DATA_VERSION_FIELD: 1,
657
        "mode": mode,
658
        "name": name,
659
        "disk_template": disk_template,
660
        "disks": disks,
661
        "nics": nics,
662
        }
663

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

    
669
      body.update((key, value) for key, value in kwargs.iteritems()
670
                  if key != "dry_run")
671
    else:
672
      # TODO: Implement instance creation request data version 0
673
      # When implementing version 0, care should be taken to refuse unknown
674
      # parameters and invalid values. The interface of this function must stay
675
      # exactly the same for version 0 and 1 (e.g. they aren't allowed to
676
      # require different data types).
677
      raise NotImplementedError("Support for instance creation request data"
678
                                " version 0 is not yet implemented")
679

    
680
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
681
                             query, body)
682

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

686
    @type instance: str
687
    @param instance: the instance to delete
688

689
    @rtype: int
690
    @return: job id
691

692
    """
693
    query = []
694
    if dry_run:
695
      query.append(("dry-run", 1))
696

    
697
    return self._SendRequest(HTTP_DELETE,
698
                             ("/%s/instances/%s" %
699
                              (GANETI_RAPI_VERSION, instance)), query, None)
700

    
701
  def GetInstanceTags(self, instance):
702
    """Gets tags for an instance.
703

704
    @type instance: str
705
    @param instance: instance whose tags to return
706

707
    @rtype: list of str
708
    @return: tags for the instance
709

710
    """
711
    return self._SendRequest(HTTP_GET,
712
                             ("/%s/instances/%s/tags" %
713
                              (GANETI_RAPI_VERSION, instance)), None, None)
714

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

718
    @type instance: str
719
    @param instance: instance to add tags to
720
    @type tags: list of str
721
    @param tags: tags to add to the instance
722
    @type dry_run: bool
723
    @param dry_run: whether to perform a dry run
724

725
    @rtype: int
726
    @return: job id
727

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

    
733
    return self._SendRequest(HTTP_PUT,
734
                             ("/%s/instances/%s/tags" %
735
                              (GANETI_RAPI_VERSION, instance)), query, None)
736

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

740
    @type instance: str
741
    @param instance: instance to delete tags from
742
    @type tags: list of str
743
    @param tags: tags to delete
744
    @type dry_run: bool
745
    @param dry_run: whether to perform a dry run
746

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

    
752
    return self._SendRequest(HTTP_DELETE,
753
                             ("/%s/instances/%s/tags" %
754
                              (GANETI_RAPI_VERSION, instance)), query, None)
755

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

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

770
    """
771
    query = []
772
    if reboot_type:
773
      query.append(("type", reboot_type))
774
    if ignore_secondaries is not None:
775
      query.append(("ignore_secondaries", ignore_secondaries))
776
    if dry_run:
777
      query.append(("dry-run", 1))
778

    
779
    return self._SendRequest(HTTP_POST,
780
                             ("/%s/instances/%s/reboot" %
781
                              (GANETI_RAPI_VERSION, instance)), query, None)
782

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

786
    @type instance: str
787
    @param instance: the instance to shut down
788
    @type dry_run: bool
789
    @param dry_run: whether to perform a dry run
790

791
    """
792
    query = []
793
    if dry_run:
794
      query.append(("dry-run", 1))
795

    
796
    return self._SendRequest(HTTP_PUT,
797
                             ("/%s/instances/%s/shutdown" %
798
                              (GANETI_RAPI_VERSION, instance)), query, None)
799

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

803
    @type instance: str
804
    @param instance: the instance to start up
805
    @type dry_run: bool
806
    @param dry_run: whether to perform a dry run
807

808
    """
809
    query = []
810
    if dry_run:
811
      query.append(("dry-run", 1))
812

    
813
    return self._SendRequest(HTTP_PUT,
814
                             ("/%s/instances/%s/startup" %
815
                              (GANETI_RAPI_VERSION, instance)), query, None)
816

    
817
  def ReinstallInstance(self, instance, os, no_startup=False):
818
    """Reinstalls an instance.
819

820
    @type instance: str
821
    @param instance: the instance to reinstall
822
    @type os: str
823
    @param os: the os to reinstall
824
    @type no_startup: bool
825
    @param no_startup: whether to start the instance automatically
826

827
    """
828
    query = [("os", os)]
829
    if no_startup:
830
      query.append(("nostartup", 1))
831
    return self._SendRequest(HTTP_POST,
832
                             ("/%s/instances/%s/reinstall" %
833
                              (GANETI_RAPI_VERSION, instance)), query, None)
834

    
835
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
836
                           remote_node=None, iallocator=None, dry_run=False):
837
    """Replaces disks on an instance.
838

839
    @type instance: str
840
    @param instance: instance whose disks to replace
841
    @type disks: list of ints
842
    @param disks: Indexes of disks to replace
843
    @type mode: str
844
    @param mode: replacement mode to use (defaults to replace_auto)
845
    @type remote_node: str or None
846
    @param remote_node: new secondary node to use (for use with
847
        replace_new_secondary mode)
848
    @type iallocator: str or None
849
    @param iallocator: instance allocator plugin to use (for use with
850
                       replace_auto mode)
851
    @type dry_run: bool
852
    @param dry_run: whether to perform a dry run
853

854
    @rtype: int
855
    @return: job id
856

857
    """
858
    query = [
859
      ("mode", mode),
860
      ]
861

    
862
    if disks:
863
      query.append(("disks", ",".join(str(idx) for idx in disks)))
864

    
865
    if remote_node:
866
      query.append(("remote_node", remote_node))
867

    
868
    if iallocator:
869
      query.append(("iallocator", iallocator))
870

    
871
    if dry_run:
872
      query.append(("dry-run", 1))
873

    
874
    return self._SendRequest(HTTP_POST,
875
                             ("/%s/instances/%s/replace-disks" %
876
                              (GANETI_RAPI_VERSION, instance)), query, None)
877

    
878
  def PrepareExport(self, instance, mode):
879
    """Prepares an instance for an export.
880

881
    @type instance: string
882
    @param instance: Instance name
883
    @type mode: string
884
    @param mode: Export mode
885
    @rtype: string
886
    @return: Job ID
887

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

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

899
    @type instance: string
900
    @param instance: Instance name
901
    @type mode: string
902
    @param mode: Export mode
903
    @rtype: string
904
    @return: Job ID
905

906
    """
907
    body = {
908
      "destination": destination,
909
      "mode": mode,
910
      }
911

    
912
    if shutdown is not None:
913
      body["shutdown"] = shutdown
914

    
915
    if remove_instance is not None:
916
      body["remove_instance"] = remove_instance
917

    
918
    if x509_key_name is not None:
919
      body["x509_key_name"] = x509_key_name
920

    
921
    if destination_x509_ca is not None:
922
      body["destination_x509_ca"] = destination_x509_ca
923

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

    
928
  def GetJobs(self):
929
    """Gets all jobs for the cluster.
930

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

934
    """
935
    return [int(j["id"])
936
            for j in self._SendRequest(HTTP_GET,
937
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
938
                                       None, None)]
939

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

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

946
    @rtype: dict
947
    @return: job status
948

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

    
954
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
955
    """Waits for job changes.
956

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

960
    """
961
    body = {
962
      "fields": fields,
963
      "previous_job_info": prev_job_info,
964
      "previous_log_serial": prev_log_serial,
965
      }
966

    
967
    return self._SendRequest(HTTP_GET,
968
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
969
                             None, body)
970

    
971
  def CancelJob(self, job_id, dry_run=False):
972
    """Cancels a job.
973

974
    @type job_id: int
975
    @param job_id: id of the job to delete
976
    @type dry_run: bool
977
    @param dry_run: whether to perform a dry run
978

979
    """
980
    query = []
981
    if dry_run:
982
      query.append(("dry-run", 1))
983

    
984
    return self._SendRequest(HTTP_DELETE,
985
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
986
                             query, None)
987

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

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

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

998
    """
999
    query = []
1000
    if bulk:
1001
      query.append(("bulk", 1))
1002

    
1003
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
1004
                              query, None)
1005
    if bulk:
1006
      return nodes
1007
    else:
1008
      return [n["id"] for n in nodes]
1009

    
1010
  def GetNode(self, node):
1011
    """Gets information about a node.
1012

1013
    @type node: str
1014
    @param node: node whose info to return
1015

1016
    @rtype: dict
1017
    @return: info about the node
1018

1019
    """
1020
    return self._SendRequest(HTTP_GET,
1021
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
1022
                             None, None)
1023

    
1024
  def EvacuateNode(self, node, iallocator=None, remote_node=None,
1025
                   dry_run=False, early_release=False):
1026
    """Evacuates instances from a Ganeti node.
1027

1028
    @type node: str
1029
    @param node: node to evacuate
1030
    @type iallocator: str or None
1031
    @param iallocator: instance allocator to use
1032
    @type remote_node: str
1033
    @param remote_node: node to evaucate to
1034
    @type dry_run: bool
1035
    @param dry_run: whether to perform a dry run
1036
    @type early_release: bool
1037
    @param early_release: whether to enable parallelization
1038

1039
    @rtype: list
1040
    @return: list of (job ID, instance name, new secondary node); if
1041
        dry_run was specified, then the actual move jobs were not
1042
        submitted and the job IDs will be C{None}
1043

1044
    @raises GanetiApiError: if an iallocator and remote_node are both
1045
        specified
1046

1047
    """
1048
    if iallocator and remote_node:
1049
      raise GanetiApiError("Only one of iallocator or remote_node can be used")
1050

    
1051
    query = []
1052
    if iallocator:
1053
      query.append(("iallocator", iallocator))
1054
    if remote_node:
1055
      query.append(("remote_node", remote_node))
1056
    if dry_run:
1057
      query.append(("dry-run", 1))
1058
    if early_release:
1059
      query.append(("early_release", 1))
1060

    
1061
    return self._SendRequest(HTTP_POST,
1062
                             ("/%s/nodes/%s/evacuate" %
1063
                              (GANETI_RAPI_VERSION, node)), query, None)
1064

    
1065
  def MigrateNode(self, node, live=True, dry_run=False):
1066
    """Migrates all primary instances from a node.
1067

1068
    @type node: str
1069
    @param node: node to migrate
1070
    @type live: bool
1071
    @param live: whether to use live migration
1072
    @type dry_run: bool
1073
    @param dry_run: whether to perform a dry run
1074

1075
    @rtype: int
1076
    @return: job id
1077

1078
    """
1079
    query = []
1080
    if live:
1081
      query.append(("live", 1))
1082
    if dry_run:
1083
      query.append(("dry-run", 1))
1084

    
1085
    return self._SendRequest(HTTP_POST,
1086
                             ("/%s/nodes/%s/migrate" %
1087
                              (GANETI_RAPI_VERSION, node)), query, None)
1088

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

1092
    @type node: str
1093
    @param node: node whose role to return
1094

1095
    @rtype: str
1096
    @return: the current role for a node
1097

1098
    """
1099
    return self._SendRequest(HTTP_GET,
1100
                             ("/%s/nodes/%s/role" %
1101
                              (GANETI_RAPI_VERSION, node)), None, None)
1102

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

1106
    @type node: str
1107
    @param node: the node whose role to set
1108
    @type role: str
1109
    @param role: the role to set for the node
1110
    @type force: bool
1111
    @param force: whether to force the role change
1112

1113
    @rtype: int
1114
    @return: job id
1115

1116
    """
1117
    query = [
1118
      ("force", force),
1119
      ]
1120

    
1121
    return self._SendRequest(HTTP_PUT,
1122
                             ("/%s/nodes/%s/role" %
1123
                              (GANETI_RAPI_VERSION, node)), query, role)
1124

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

1128
    @type node: str
1129
    @param node: the node whose storage units to return
1130
    @type storage_type: str
1131
    @param storage_type: storage type whose units to return
1132
    @type output_fields: str
1133
    @param output_fields: storage type fields to return
1134

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

1138
    """
1139
    query = [
1140
      ("storage_type", storage_type),
1141
      ("output_fields", output_fields),
1142
      ]
1143

    
1144
    return self._SendRequest(HTTP_GET,
1145
                             ("/%s/nodes/%s/storage" %
1146
                              (GANETI_RAPI_VERSION, node)), query, None)
1147

    
1148
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
1149
    """Modifies parameters of storage units on the node.
1150

1151
    @type node: str
1152
    @param node: node whose storage units to modify
1153
    @type storage_type: str
1154
    @param storage_type: storage type whose units to modify
1155
    @type name: str
1156
    @param name: name of the storage unit
1157
    @type allocatable: bool or None
1158
    @param allocatable: Whether to set the "allocatable" flag on the storage
1159
                        unit (None=no modification, True=set, False=unset)
1160

1161
    @rtype: int
1162
    @return: job id
1163

1164
    """
1165
    query = [
1166
      ("storage_type", storage_type),
1167
      ("name", name),
1168
      ]
1169

    
1170
    if allocatable is not None:
1171
      query.append(("allocatable", allocatable))
1172

    
1173
    return self._SendRequest(HTTP_PUT,
1174
                             ("/%s/nodes/%s/storage/modify" %
1175
                              (GANETI_RAPI_VERSION, node)), query, None)
1176

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

1180
    @type node: str
1181
    @param node: node whose storage units to repair
1182
    @type storage_type: str
1183
    @param storage_type: storage type to repair
1184
    @type name: str
1185
    @param name: name of the storage unit to repair
1186

1187
    @rtype: int
1188
    @return: job id
1189

1190
    """
1191
    query = [
1192
      ("storage_type", storage_type),
1193
      ("name", name),
1194
      ]
1195

    
1196
    return self._SendRequest(HTTP_PUT,
1197
                             ("/%s/nodes/%s/storage/repair" %
1198
                              (GANETI_RAPI_VERSION, node)), query, None)
1199

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

1203
    @type node: str
1204
    @param node: node whose tags to return
1205

1206
    @rtype: list of str
1207
    @return: tags for the node
1208

1209
    """
1210
    return self._SendRequest(HTTP_GET,
1211
                             ("/%s/nodes/%s/tags" %
1212
                              (GANETI_RAPI_VERSION, node)), None, None)
1213

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

1217
    @type node: str
1218
    @param node: node to add tags to
1219
    @type tags: list of str
1220
    @param tags: tags to add to the node
1221
    @type dry_run: bool
1222
    @param dry_run: whether to perform a dry run
1223

1224
    @rtype: int
1225
    @return: job id
1226

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

    
1232
    return self._SendRequest(HTTP_PUT,
1233
                             ("/%s/nodes/%s/tags" %
1234
                              (GANETI_RAPI_VERSION, node)), query, tags)
1235

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

1239
    @type node: str
1240
    @param node: node to remove tags from
1241
    @type tags: list of str
1242
    @param tags: tags to remove from the node
1243
    @type dry_run: bool
1244
    @param dry_run: whether to perform a dry run
1245

1246
    @rtype: int
1247
    @return: job id
1248

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

    
1254
    return self._SendRequest(HTTP_DELETE,
1255
                             ("/%s/nodes/%s/tags" %
1256
                              (GANETI_RAPI_VERSION, node)), query, None)