Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ 48436b97

History | View | Annotate | Download (36.9 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
_INST_NIC_PARAMS = frozenset(["mac", "ip", "mode", "link", "bridge"])
64
_INST_CREATE_V0_DISK_PARAMS = frozenset(["size"])
65
_INST_CREATE_V0_PARAMS = frozenset([
66
  "os", "pnode", "snode", "iallocator", "start", "ip_check", "name_check",
67
  "hypervisor", "file_storage_dir", "file_driver", "dry_run",
68
  ])
69
_INST_CREATE_V0_DPARAMS = frozenset(["beparams", "hvparams"])
70

    
71

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

75
  """
76
  pass
77

    
78

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

82
  """
83
  pass
84

    
85

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

89
  """
90
  def __init__(self, msg, code=None):
91
    Error.__init__(self, msg)
92
    self.code = code
93

    
94

    
95
def FormatX509Name(x509_name):
96
  """Formats an X509 name.
97

98
  @type x509_name: OpenSSL.crypto.X509Name
99

100
  """
101
  try:
102
    # Only supported in pyOpenSSL 0.7 and above
103
    get_components_fn = x509_name.get_components
104
  except AttributeError:
105
    return repr(x509_name)
106
  else:
107
    return "".join("/%s=%s" % (name, value)
108
                   for name, value in get_components_fn())
109

    
110

    
111
class CertAuthorityVerify:
112
  """Certificate verificator for SSL context.
113

114
  Configures SSL context to verify server's certificate.
115

116
  """
117
  _CAPATH_MINVERSION = "0.9"
118
  _DEFVFYPATHS_MINVERSION = "0.9"
119

    
120
  _PYOPENSSL_VERSION = OpenSSL.__version__
121
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
122

    
123
  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
124
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
125

    
126
  def __init__(self, cafile=None, capath=None, use_default_verify_paths=False):
127
    """Initializes this class.
128

129
    @type cafile: string
130
    @param cafile: In which file we can find the certificates
131
    @type capath: string
132
    @param capath: In which directory we can find the certificates
133
    @type use_default_verify_paths: bool
134
    @param use_default_verify_paths: Whether the platform provided CA
135
                                     certificates are to be used for
136
                                     verification purposes
137

138
    """
139
    self._cafile = cafile
140
    self._capath = capath
141
    self._use_default_verify_paths = use_default_verify_paths
142

    
143
    if self._capath is not None and not self._SUPPORT_CAPATH:
144
      raise Error(("PyOpenSSL %s has no support for a CA directory,"
145
                   " version %s or above is required") %
146
                  (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION))
147

    
148
    if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS:
149
      raise Error(("PyOpenSSL %s has no support for using default verification"
150
                   " paths, version %s or above is required") %
151
                  (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION))
152

    
153
  @staticmethod
154
  def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok):
155
    """Callback for SSL certificate verification.
156

157
    @param logger: Logging object
158

159
    """
160
    if ok:
161
      log_fn = logger.debug
162
    else:
163
      log_fn = logger.error
164

    
165
    log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'",
166
           errdepth, FormatX509Name(cert.get_subject()),
167
           FormatX509Name(cert.get_issuer()))
168

    
169
    if not ok:
170
      try:
171
        # Only supported in pyOpenSSL 0.7 and above
172
        # pylint: disable-msg=E1101
173
        fn = OpenSSL.crypto.X509_verify_cert_error_string
174
      except AttributeError:
175
        errmsg = ""
176
      else:
177
        errmsg = ":%s" % fn(errnum)
178

    
179
      logger.error("verify error:num=%s%s", errnum, errmsg)
180

    
181
    return ok
182

    
183
  def __call__(self, ctx, logger):
184
    """Configures an SSL context to verify certificates.
185

186
    @type ctx: OpenSSL.SSL.Context
187
    @param ctx: SSL context
188

189
    """
190
    if self._use_default_verify_paths:
191
      ctx.set_default_verify_paths()
192

    
193
    if self._cafile or self._capath:
194
      if self._SUPPORT_CAPATH:
195
        ctx.load_verify_locations(self._cafile, self._capath)
196
      else:
197
        ctx.load_verify_locations(self._cafile)
198

    
199
    ctx.set_verify(OpenSSL.SSL.VERIFY_PEER,
200
                   lambda conn, cert, errnum, errdepth, ok: \
201
                     self._VerifySslCertCb(logger, conn, cert,
202
                                           errnum, errdepth, ok))
203

    
204

    
205
class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
206
  """HTTPS Connection handler that verifies the SSL certificate.
207

208
  """
209
  # Python before version 2.6 had its own httplib.FakeSocket wrapper for
210
  # sockets
211
  _SUPPORT_FAKESOCKET = (sys.hexversion < 0x2060000)
212

    
213
  def __init__(self, *args, **kwargs):
214
    """Initializes this class.
215

216
    """
217
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
218
    self._logger = None
219
    self._config_ssl_verification = None
220

    
221
  def Setup(self, logger, config_ssl_verification):
222
    """Sets the SSL verification config function.
223

224
    @param logger: Logging object
225
    @type config_ssl_verification: callable
226

227
    """
228
    assert self._logger is None
229
    assert self._config_ssl_verification is None
230

    
231
    self._logger = logger
232
    self._config_ssl_verification = config_ssl_verification
233

    
234
  def connect(self):
235
    """Connect to the server specified when the object was created.
236

237
    This ensures that SSL certificates are verified.
238

239
    """
240
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
241

    
242
    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
243
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
244

    
245
    if self._config_ssl_verification:
246
      self._config_ssl_verification(ctx, self._logger)
247

    
248
    ssl = OpenSSL.SSL.Connection(ctx, sock)
249
    ssl.connect((self.host, self.port))
250

    
251
    if self._SUPPORT_FAKESOCKET:
252
      self.sock = httplib.FakeSocket(sock, ssl)
253
    else:
254
      self.sock = _SslSocketWrapper(ssl)
255

    
256

    
257
class _SslSocketWrapper(object):
258
  def __init__(self, sock):
259
    """Initializes this class.
260

261
    """
262
    self._sock = sock
263

    
264
  def __getattr__(self, name):
265
    """Forward everything to underlying socket.
266

267
    """
268
    return getattr(self._sock, name)
269

    
270
  def makefile(self, mode, bufsize):
271
    """Fake makefile method.
272

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

277
    """
278
    # pylint: disable-msg=W0212
279
    return socket._fileobject(self._sock, mode, bufsize)
280

    
281

    
282
class _HTTPSHandler(urllib2.HTTPSHandler):
283
  def __init__(self, logger, config_ssl_verification):
284
    """Initializes this class.
285

286
    @param logger: Logging object
287
    @type config_ssl_verification: callable
288
    @param config_ssl_verification: Function to configure SSL context for
289
                                    certificate verification
290

291
    """
292
    urllib2.HTTPSHandler.__init__(self)
293
    self._logger = logger
294
    self._config_ssl_verification = config_ssl_verification
295

    
296
  def _CreateHttpsConnection(self, *args, **kwargs):
297
    """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification.
298

299
    This wrapper is necessary provide a compatible API to urllib2.
300

301
    """
302
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
303
    conn.Setup(self._logger, self._config_ssl_verification)
304
    return conn
305

    
306
  def https_open(self, req):
307
    """Creates HTTPS connection.
308

309
    Called by urllib2.
310

311
    """
312
    return self.do_open(self._CreateHttpsConnection, req)
313

    
314

    
315
class _RapiRequest(urllib2.Request):
316
  def __init__(self, method, url, headers, data):
317
    """Initializes this class.
318

319
    """
320
    urllib2.Request.__init__(self, url, data=data, headers=headers)
321
    self._method = method
322

    
323
  def get_method(self):
324
    """Returns the HTTP request method.
325

326
    """
327
    return self._method
328

    
329

    
330
class GanetiRapiClient(object):
331
  """Ganeti RAPI client.
332

333
  """
334
  USER_AGENT = "Ganeti RAPI Client"
335
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
336

    
337
  def __init__(self, host, port=GANETI_RAPI_PORT,
338
               username=None, password=None,
339
               config_ssl_verification=None, ignore_proxy=False,
340
               logger=logging):
341
    """Constructor.
342

343
    @type host: string
344
    @param host: the ganeti cluster master to interact with
345
    @type port: int
346
    @param port: the port on which the RAPI is running (default is 5080)
347
    @type username: string
348
    @param username: the username to connect with
349
    @type password: string
350
    @param password: the password to connect with
351
    @type config_ssl_verification: callable
352
    @param config_ssl_verification: Function to configure SSL context for
353
                                    certificate verification
354
    @type ignore_proxy: bool
355
    @param ignore_proxy: Whether to ignore proxy settings
356
    @param logger: Logging object
357

358
    """
359
    self._host = host
360
    self._port = port
361
    self._logger = logger
362

    
363
    self._base_url = "https://%s:%s" % (host, port)
364

    
365
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
366

    
367
    self._httpauthhandler = None
368
    if username is not None:
369
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
370
      pwmgr.add_password(None, self._base_url, username, password)
371
      self._httpauthhandler = urllib2.HTTPBasicAuthHandler(pwmgr)
372
      handlers.append(self._httpauthhandler)
373
    elif password:
374
      raise Error("Specified password without username")
375

    
376
    if ignore_proxy:
377
      handlers.append(urllib2.ProxyHandler({}))
378

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

    
381
    self._headers = {
382
      "Accept": HTTP_APP_JSON,
383
      "Content-type": HTTP_APP_JSON,
384
      "User-Agent": self.USER_AGENT,
385
      }
386

    
387
  @staticmethod
388
  def _EncodeQuery(query):
389
    """Encode query values for RAPI URL.
390

391
    @type query: list of two-tuples
392
    @param query: Query arguments
393
    @rtype: list
394
    @return: Query list with encoded values
395

396
    """
397
    result = []
398

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

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

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

    
410
      else:
411
        result.append((name, value))
412

    
413
    return result
414

    
415
  def _SendRequest(self, method, path, query, content):
416
    """Sends an HTTP request.
417

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

421
    @type method: string
422
    @param method: HTTP method to use
423
    @type path: string
424
    @param path: HTTP URL path
425
    @type query: list of two-tuples
426
    @param query: query arguments to pass to urllib.urlencode
427
    @type content: str or None
428
    @param content: HTTP body content
429

430
    @rtype: str
431
    @return: JSON-Decoded response
432

433
    @raises CertificateError: If an invalid SSL certificate is found
434
    @raises GanetiApiError: If an invalid response is returned
435

436
    """
437
    assert path.startswith("/")
438

    
439
    if content:
440
      encoded_content = self._json_encoder.encode(content)
441
    else:
442
      encoded_content = None
443

    
444
    # Build URL
445
    urlparts = [self._base_url, path]
446
    if query:
447
      urlparts.append("?")
448
      urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
449

    
450
    url = "".join(urlparts)
451

    
452
    self._logger.debug("Sending request %s %s to %s:%s"
453
                       " (headers=%r, content=%r)",
454
                       method, url, self._host, self._port, self._headers,
455
                       encoded_content)
456

    
457
    req = _RapiRequest(method, url, self._headers, encoded_content)
458

    
459
    try:
460
      resp = self._http.open(req)
461
      encoded_response_content = resp.read()
462
      # Workaround for python 2.6: reset the retried count of the http handler,
463
      # or it'll fail after ~5 requests made with the same client. Under python
464
      # 2.4 and 2.5 this variable is not used.
465
      if self._httpauthhandler is not None:
466
        self._httpauthhandler.retried = 0
467
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
468
      raise CertificateError("SSL issue: %s (%r)" % (err, err))
469
    except urllib2.HTTPError, err:
470
      raise GanetiApiError(str(err), code=err.code)
471
    except urllib2.URLError, err:
472
      raise GanetiApiError(str(err))
473

    
474
    if encoded_response_content:
475
      response_content = simplejson.loads(encoded_response_content)
476
    else:
477
      response_content = None
478

    
479
    # TODO: Are there other status codes that are valid? (redirect?)
480
    if resp.code != HTTP_OK:
481
      if isinstance(response_content, dict):
482
        msg = ("%s %s: %s" %
483
               (response_content["code"],
484
                response_content["message"],
485
                response_content["explain"]))
486
      else:
487
        msg = str(response_content)
488

    
489
      raise GanetiApiError(msg, code=resp.code)
490

    
491
    return response_content
492

    
493
  def GetVersion(self):
494
    """Gets the Remote API version running on the cluster.
495

496
    @rtype: int
497
    @return: Ganeti Remote API version
498

499
    """
500
    return self._SendRequest(HTTP_GET, "/version", None, None)
501

    
502
  def GetFeatures(self):
503
    """Gets the list of optional features supported by RAPI server.
504

505
    @rtype: list
506
    @return: List of optional features
507

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

    
517
      raise
518

    
519
  def GetOperatingSystems(self):
520
    """Gets the Operating Systems running in the Ganeti cluster.
521

522
    @rtype: list of str
523
    @return: operating systems
524

525
    """
526
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
527
                             None, None)
528

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

532
    @rtype: dict
533
    @return: information about the cluster
534

535
    """
536
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
537
                             None, None)
538

    
539
  def GetClusterTags(self):
540
    """Gets the cluster tags.
541

542
    @rtype: list of str
543
    @return: cluster tags
544

545
    """
546
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
547
                             None, None)
548

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

552
    @type tags: list of str
553
    @param tags: tags to add to the cluster
554
    @type dry_run: bool
555
    @param dry_run: whether to perform a dry run
556

557
    @rtype: int
558
    @return: job id
559

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

    
565
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
566
                             query, None)
567

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

571
    @type tags: list of str
572
    @param tags: tags to delete
573
    @type dry_run: bool
574
    @param dry_run: whether to perform a dry run
575

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

    
581
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
582
                             query, None)
583

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

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

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

593
    """
594
    query = []
595
    if bulk:
596
      query.append(("bulk", 1))
597

    
598
    instances = self._SendRequest(HTTP_GET,
599
                                  "/%s/instances" % GANETI_RAPI_VERSION,
600
                                  query, None)
601
    if bulk:
602
      return instances
603
    else:
604
      return [i["id"] for i in instances]
605

    
606
  def GetInstance(self, instance):
607
    """Gets information about an instance.
608

609
    @type instance: str
610
    @param instance: instance whose info to return
611

612
    @rtype: dict
613
    @return: info about the instance
614

615
    """
616
    return self._SendRequest(HTTP_GET,
617
                             ("/%s/instances/%s" %
618
                              (GANETI_RAPI_VERSION, instance)), None, None)
619

    
620
  def GetInstanceInfo(self, instance, static=None):
621
    """Gets information about an instance.
622

623
    @type instance: string
624
    @param instance: Instance name
625
    @rtype: string
626
    @return: Job ID
627

628
    """
629
    if static is not None:
630
      query = [("static", static)]
631
    else:
632
      query = None
633

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

    
638
  def CreateInstance(self, mode, name, disk_template, disks, nics,
639
                     **kwargs):
640
    """Creates a new instance.
641

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

644
    @type mode: string
645
    @param mode: Instance creation mode
646
    @type name: string
647
    @param name: Hostname of the instance to create
648
    @type disk_template: string
649
    @param disk_template: Disk template for instance (e.g. plain, diskless,
650
                          file, or drbd)
651
    @type disks: list of dicts
652
    @param disks: List of disk definitions
653
    @type nics: list of dicts
654
    @param nics: List of NIC definitions
655
    @type dry_run: bool
656
    @keyword dry_run: whether to perform a dry run
657

658
    @rtype: int
659
    @return: job id
660

661
    """
662
    query = []
663

    
664
    if kwargs.get("dry_run"):
665
      query.append(("dry-run", 1))
666

    
667
    if _INST_CREATE_REQV1 in self.GetFeatures():
668
      # All required fields for request data version 1
669
      body = {
670
        _REQ_DATA_VERSION_FIELD: 1,
671
        "mode": mode,
672
        "name": name,
673
        "disk_template": disk_template,
674
        "disks": disks,
675
        "nics": nics,
676
        }
677

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

    
683
      body.update((key, value) for key, value in kwargs.iteritems()
684
                  if key != "dry_run")
685
    else:
686
      # Old request format (version 0)
687

    
688
      # The following code must make sure that an exception is raised when an
689
      # unsupported setting is requested by the caller. Otherwise this can lead
690
      # to bugs difficult to find. The interface of this function must stay
691
      # exactly the same for version 0 and 1 (e.g. they aren't allowed to
692
      # require different data types).
693

    
694
      # Validate disks
695
      for idx, disk in enumerate(disks):
696
        unsupported = set(disk.keys()) - _INST_CREATE_V0_DISK_PARAMS
697
        if unsupported:
698
          raise GanetiApiError("Server supports request version 0 only, but"
699
                               " disk %s specifies the unsupported parameters"
700
                               " %s, allowed are %s" %
701
                               (idx, unsupported,
702
                                list(_INST_CREATE_V0_DISK_PARAMS)))
703

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

    
708
      # Validate NICs
709
      if not nics:
710
        raise GanetiApiError("Server supports request version 0 only, but"
711
                             " no NIC specified")
712
      elif len(nics) > 1:
713
        raise GanetiApiError("Server supports request version 0 only, but"
714
                             " more than one NIC specified")
715

    
716
      assert len(nics) == 1
717

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

    
725
      # Validate other parameters
726
      unsupported = (set(kwargs.keys()) - _INST_CREATE_V0_PARAMS -
727
                     _INST_CREATE_V0_DPARAMS)
728
      if unsupported:
729
        allowed = _INST_CREATE_V0_PARAMS.union(_INST_CREATE_V0_DPARAMS)
730
        raise GanetiApiError("Server supports request version 0 only, but"
731
                             " the following unsupported parameters are"
732
                             " specified: %s, allowed are %s" %
733
                             (unsupported, list(allowed)))
734

    
735
      # All required fields for request data version 0
736
      body = {
737
        _REQ_DATA_VERSION_FIELD: 0,
738
        "name": name,
739
        "disk_template": disk_template,
740
        "disks": disk_sizes,
741
        }
742

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

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

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

    
759
      assert not (set(kwargs.keys()) -
760
                  (_INST_CREATE_V0_PARAMS | _INST_CREATE_V0_DPARAMS))
761
      assert not (set(body.keys()) & _INST_CREATE_V0_DPARAMS)
762

    
763
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
764
                             query, body)
765

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

769
    @type instance: str
770
    @param instance: the instance to delete
771

772
    @rtype: int
773
    @return: job id
774

775
    """
776
    query = []
777
    if dry_run:
778
      query.append(("dry-run", 1))
779

    
780
    return self._SendRequest(HTTP_DELETE,
781
                             ("/%s/instances/%s" %
782
                              (GANETI_RAPI_VERSION, instance)), query, None)
783

    
784
  def GetInstanceTags(self, instance):
785
    """Gets tags for an instance.
786

787
    @type instance: str
788
    @param instance: instance whose tags to return
789

790
    @rtype: list of str
791
    @return: tags for the instance
792

793
    """
794
    return self._SendRequest(HTTP_GET,
795
                             ("/%s/instances/%s/tags" %
796
                              (GANETI_RAPI_VERSION, instance)), None, None)
797

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

801
    @type instance: str
802
    @param instance: instance to add tags to
803
    @type tags: list of str
804
    @param tags: tags to add to the instance
805
    @type dry_run: bool
806
    @param dry_run: whether to perform a dry run
807

808
    @rtype: int
809
    @return: job id
810

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

    
816
    return self._SendRequest(HTTP_PUT,
817
                             ("/%s/instances/%s/tags" %
818
                              (GANETI_RAPI_VERSION, instance)), query, None)
819

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

823
    @type instance: str
824
    @param instance: instance to delete tags from
825
    @type tags: list of str
826
    @param tags: tags to delete
827
    @type dry_run: bool
828
    @param dry_run: whether to perform a dry run
829

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

    
835
    return self._SendRequest(HTTP_DELETE,
836
                             ("/%s/instances/%s/tags" %
837
                              (GANETI_RAPI_VERSION, instance)), query, None)
838

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

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

853
    """
854
    query = []
855
    if reboot_type:
856
      query.append(("type", reboot_type))
857
    if ignore_secondaries is not None:
858
      query.append(("ignore_secondaries", ignore_secondaries))
859
    if dry_run:
860
      query.append(("dry-run", 1))
861

    
862
    return self._SendRequest(HTTP_POST,
863
                             ("/%s/instances/%s/reboot" %
864
                              (GANETI_RAPI_VERSION, instance)), query, None)
865

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

869
    @type instance: str
870
    @param instance: the instance to shut down
871
    @type dry_run: bool
872
    @param dry_run: whether to perform a dry run
873

874
    """
875
    query = []
876
    if dry_run:
877
      query.append(("dry-run", 1))
878

    
879
    return self._SendRequest(HTTP_PUT,
880
                             ("/%s/instances/%s/shutdown" %
881
                              (GANETI_RAPI_VERSION, instance)), query, None)
882

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

886
    @type instance: str
887
    @param instance: the instance to start up
888
    @type dry_run: bool
889
    @param dry_run: whether to perform a dry run
890

891
    """
892
    query = []
893
    if dry_run:
894
      query.append(("dry-run", 1))
895

    
896
    return self._SendRequest(HTTP_PUT,
897
                             ("/%s/instances/%s/startup" %
898
                              (GANETI_RAPI_VERSION, instance)), query, None)
899

    
900
  def ReinstallInstance(self, instance, os, no_startup=False):
901
    """Reinstalls an instance.
902

903
    @type instance: str
904
    @param instance: the instance to reinstall
905
    @type os: str
906
    @param os: the os to reinstall
907
    @type no_startup: bool
908
    @param no_startup: whether to start the instance automatically
909

910
    """
911
    query = [("os", os)]
912
    if no_startup:
913
      query.append(("nostartup", 1))
914
    return self._SendRequest(HTTP_POST,
915
                             ("/%s/instances/%s/reinstall" %
916
                              (GANETI_RAPI_VERSION, instance)), query, None)
917

    
918
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
919
                           remote_node=None, iallocator=None, dry_run=False):
920
    """Replaces disks on an instance.
921

922
    @type instance: str
923
    @param instance: instance whose disks to replace
924
    @type disks: list of ints
925
    @param disks: Indexes of disks to replace
926
    @type mode: str
927
    @param mode: replacement mode to use (defaults to replace_auto)
928
    @type remote_node: str or None
929
    @param remote_node: new secondary node to use (for use with
930
        replace_new_secondary mode)
931
    @type iallocator: str or None
932
    @param iallocator: instance allocator plugin to use (for use with
933
                       replace_auto mode)
934
    @type dry_run: bool
935
    @param dry_run: whether to perform a dry run
936

937
    @rtype: int
938
    @return: job id
939

940
    """
941
    query = [
942
      ("mode", mode),
943
      ]
944

    
945
    if disks:
946
      query.append(("disks", ",".join(str(idx) for idx in disks)))
947

    
948
    if remote_node:
949
      query.append(("remote_node", remote_node))
950

    
951
    if iallocator:
952
      query.append(("iallocator", iallocator))
953

    
954
    if dry_run:
955
      query.append(("dry-run", 1))
956

    
957
    return self._SendRequest(HTTP_POST,
958
                             ("/%s/instances/%s/replace-disks" %
959
                              (GANETI_RAPI_VERSION, instance)), query, None)
960

    
961
  def GetJobs(self):
962
    """Gets all jobs for the cluster.
963

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

967
    """
968
    return [int(j["id"])
969
            for j in self._SendRequest(HTTP_GET,
970
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
971
                                       None, None)]
972

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

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

979
    @rtype: dict
980
    @return: job status
981

982
    """
983
    return self._SendRequest(HTTP_GET,
984
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
985
                             None, None)
986

    
987
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
988
    """Waits for job changes.
989

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

993
    """
994
    body = {
995
      "fields": fields,
996
      "previous_job_info": prev_job_info,
997
      "previous_log_serial": prev_log_serial,
998
      }
999

    
1000
    return self._SendRequest(HTTP_GET,
1001
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
1002
                             None, body)
1003

    
1004
  def CancelJob(self, job_id, dry_run=False):
1005
    """Cancels a job.
1006

1007
    @type job_id: int
1008
    @param job_id: id of the job to delete
1009
    @type dry_run: bool
1010
    @param dry_run: whether to perform a dry run
1011

1012
    """
1013
    query = []
1014
    if dry_run:
1015
      query.append(("dry-run", 1))
1016

    
1017
    return self._SendRequest(HTTP_DELETE,
1018
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
1019
                             query, None)
1020

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

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

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

1031
    """
1032
    query = []
1033
    if bulk:
1034
      query.append(("bulk", 1))
1035

    
1036
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
1037
                              query, None)
1038
    if bulk:
1039
      return nodes
1040
    else:
1041
      return [n["id"] for n in nodes]
1042

    
1043
  def GetNode(self, node):
1044
    """Gets information about a node.
1045

1046
    @type node: str
1047
    @param node: node whose info to return
1048

1049
    @rtype: dict
1050
    @return: info about the node
1051

1052
    """
1053
    return self._SendRequest(HTTP_GET,
1054
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
1055
                             None, None)
1056

    
1057
  def EvacuateNode(self, node, iallocator=None, remote_node=None,
1058
                   dry_run=False):
1059
    """Evacuates instances from a Ganeti node.
1060

1061
    @type node: str
1062
    @param node: node to evacuate
1063
    @type iallocator: str or None
1064
    @param iallocator: instance allocator to use
1065
    @type remote_node: str
1066
    @param remote_node: node to evaucate to
1067
    @type dry_run: bool
1068
    @param dry_run: whether to perform a dry run
1069

1070
    @rtype: int
1071
    @return: job id
1072

1073
    @raises GanetiApiError: if an iallocator and remote_node are both specified
1074

1075
    """
1076
    if iallocator and remote_node:
1077
      raise GanetiApiError("Only one of iallocator or remote_node can be used")
1078

    
1079
    query = []
1080
    if iallocator:
1081
      query.append(("iallocator", iallocator))
1082
    if remote_node:
1083
      query.append(("remote_node", remote_node))
1084
    if dry_run:
1085
      query.append(("dry-run", 1))
1086

    
1087
    return self._SendRequest(HTTP_POST,
1088
                             ("/%s/nodes/%s/evacuate" %
1089
                              (GANETI_RAPI_VERSION, node)), query, None)
1090

    
1091
  def MigrateNode(self, node, live=True, dry_run=False):
1092
    """Migrates all primary instances from a node.
1093

1094
    @type node: str
1095
    @param node: node to migrate
1096
    @type live: bool
1097
    @param live: whether to use live migration
1098
    @type dry_run: bool
1099
    @param dry_run: whether to perform a dry run
1100

1101
    @rtype: int
1102
    @return: job id
1103

1104
    """
1105
    query = []
1106
    if live:
1107
      query.append(("live", 1))
1108
    if dry_run:
1109
      query.append(("dry-run", 1))
1110

    
1111
    return self._SendRequest(HTTP_POST,
1112
                             ("/%s/nodes/%s/migrate" %
1113
                              (GANETI_RAPI_VERSION, node)), query, None)
1114

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

1118
    @type node: str
1119
    @param node: node whose role to return
1120

1121
    @rtype: str
1122
    @return: the current role for a node
1123

1124
    """
1125
    return self._SendRequest(HTTP_GET,
1126
                             ("/%s/nodes/%s/role" %
1127
                              (GANETI_RAPI_VERSION, node)), None, None)
1128

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

1132
    @type node: str
1133
    @param node: the node whose role to set
1134
    @type role: str
1135
    @param role: the role to set for the node
1136
    @type force: bool
1137
    @param force: whether to force the role change
1138

1139
    @rtype: int
1140
    @return: job id
1141

1142
    """
1143
    query = [
1144
      ("force", force),
1145
      ]
1146

    
1147
    return self._SendRequest(HTTP_PUT,
1148
                             ("/%s/nodes/%s/role" %
1149
                              (GANETI_RAPI_VERSION, node)), query, role)
1150

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

1154
    @type node: str
1155
    @param node: the node whose storage units to return
1156
    @type storage_type: str
1157
    @param storage_type: storage type whose units to return
1158
    @type output_fields: str
1159
    @param output_fields: storage type fields to return
1160

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

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

    
1170
    return self._SendRequest(HTTP_GET,
1171
                             ("/%s/nodes/%s/storage" %
1172
                              (GANETI_RAPI_VERSION, node)), query, None)
1173

    
1174
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
1175
    """Modifies parameters of storage units on the node.
1176

1177
    @type node: str
1178
    @param node: node whose storage units to modify
1179
    @type storage_type: str
1180
    @param storage_type: storage type whose units to modify
1181
    @type name: str
1182
    @param name: name of the storage unit
1183
    @type allocatable: bool or None
1184
    @param allocatable: Whether to set the "allocatable" flag on the storage
1185
                        unit (None=no modification, True=set, False=unset)
1186

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

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

    
1196
    if allocatable is not None:
1197
      query.append(("allocatable", allocatable))
1198

    
1199
    return self._SendRequest(HTTP_PUT,
1200
                             ("/%s/nodes/%s/storage/modify" %
1201
                              (GANETI_RAPI_VERSION, node)), query, None)
1202

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

1206
    @type node: str
1207
    @param node: node whose storage units to repair
1208
    @type storage_type: str
1209
    @param storage_type: storage type to repair
1210
    @type name: str
1211
    @param name: name of the storage unit to repair
1212

1213
    @rtype: int
1214
    @return: job id
1215

1216
    """
1217
    query = [
1218
      ("storage_type", storage_type),
1219
      ("name", name),
1220
      ]
1221

    
1222
    return self._SendRequest(HTTP_PUT,
1223
                             ("/%s/nodes/%s/storage/repair" %
1224
                              (GANETI_RAPI_VERSION, node)), query, None)
1225

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

1229
    @type node: str
1230
    @param node: node whose tags to return
1231

1232
    @rtype: list of str
1233
    @return: tags for the node
1234

1235
    """
1236
    return self._SendRequest(HTTP_GET,
1237
                             ("/%s/nodes/%s/tags" %
1238
                              (GANETI_RAPI_VERSION, node)), None, None)
1239

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

1243
    @type node: str
1244
    @param node: node to add tags to
1245
    @type tags: list of str
1246
    @param tags: tags to add to the node
1247
    @type dry_run: bool
1248
    @param dry_run: whether to perform a dry run
1249

1250
    @rtype: int
1251
    @return: job id
1252

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

    
1258
    return self._SendRequest(HTTP_PUT,
1259
                             ("/%s/nodes/%s/tags" %
1260
                              (GANETI_RAPI_VERSION, node)), query, tags)
1261

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

1265
    @type node: str
1266
    @param node: node to remove tags from
1267
    @type tags: list of str
1268
    @param tags: tags to remove from the node
1269
    @type dry_run: bool
1270
    @param dry_run: whether to perform a dry run
1271

1272
    @rtype: int
1273
    @return: job id
1274

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

    
1280
    return self._SendRequest(HTTP_DELETE,
1281
                             ("/%s/nodes/%s/tags" %
1282
                              (GANETI_RAPI_VERSION, node)), query, None)