client.py 29.5 KB
Newer Older
David Knowles's avatar
David Knowles committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#
#

# Copyright (C) 2010 Google Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.


"""Ganeti RAPI client."""

import httplib
25
26
import urllib2
import logging
David Knowles's avatar
David Knowles committed
27
28
29
import simplejson
import socket
import urllib
30
31
import OpenSSL
import distutils.version
David Knowles's avatar
David Knowles committed
32
33


34
GANETI_RAPI_PORT = 5080
35
GANETI_RAPI_VERSION = 2
36

David Knowles's avatar
David Knowles committed
37
38
39
40
HTTP_DELETE = "DELETE"
HTTP_GET = "GET"
HTTP_PUT = "PUT"
HTTP_POST = "POST"
41
42
43
HTTP_OK = 200
HTTP_APP_JSON = "application/json"

David Knowles's avatar
David Knowles committed
44
45
46
47
REPLACE_DISK_PRI = "replace_on_primary"
REPLACE_DISK_SECONDARY = "replace_on_secondary"
REPLACE_DISK_CHG = "replace_new_secondary"
REPLACE_DISK_AUTO = "replace_auto"
48
49
50
51
52
53

NODE_ROLE_DRAINED = "drained"
NODE_ROLE_MASTER_CANDIATE = "master-candidate"
NODE_ROLE_MASTER = "master"
NODE_ROLE_OFFLINE = "offline"
NODE_ROLE_REGULAR = "regular"
David Knowles's avatar
David Knowles committed
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73


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

  """
  pass


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

  """
  pass


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

  """
74
75
76
  def __init__(self, msg, code=None):
    Error.__init__(self, msg)
    self.code = code
David Knowles's avatar
David Knowles committed
77
78


79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def FormatX509Name(x509_name):
  """Formats an X509 name.

  @type x509_name: OpenSSL.crypto.X509Name

  """
  try:
    # Only supported in pyOpenSSL 0.7 and above
    get_components_fn = x509_name.get_components
  except AttributeError:
    return repr(x509_name)
  else:
    return "".join("/%s=%s" % (name, value)
                   for name, value in get_components_fn())


class CertAuthorityVerify:
  """Certificate verificator for SSL context.

  Configures SSL context to verify server's certificate.

  """
  _CAPATH_MINVERSION = "0.9"
  _DEFVFYPATHS_MINVERSION = "0.9"

  _PYOPENSSL_VERSION = OpenSSL.__version__
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)

  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)

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

    @type cafile: string
    @param cafile: In which file we can find the certificates
    @type capath: string
    @param capath: In which directory we can find the certificates
    @type use_default_verify_paths: bool
    @param use_default_verify_paths: Whether the platform provided CA
                                     certificates are to be used for
                                     verification purposes

    """
    self._cafile = cafile
    self._capath = capath
    self._use_default_verify_paths = use_default_verify_paths

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

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

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

    @param logger: Logging object

    """
    if ok:
      log_fn = logger.debug
    else:
      log_fn = logger.error

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

    if not ok:
      try:
        # Only supported in pyOpenSSL 0.7 and above
        # pylint: disable-msg=E1101
        fn = OpenSSL.crypto.X509_verify_cert_error_string
      except AttributeError:
        errmsg = ""
      else:
        errmsg = ":%s" % fn(errnum)

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

    return ok

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

    @type ctx: OpenSSL.SSL.Context
    @param ctx: SSL context

    """
    if self._use_default_verify_paths:
      ctx.set_default_verify_paths()

    if self._cafile or self._capath:
      if self._SUPPORT_CAPATH:
        ctx.load_verify_locations(self._cafile, self._capath)
      else:
        ctx.load_verify_locations(self._cafile)

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


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

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

    """
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
    self._logger = None
    self._config_ssl_verification = None

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

    @param logger: Logging object
    @type config_ssl_verification: callable

    """
    assert self._logger is None
    assert self._config_ssl_verification is None

    self._logger = logger
    self._config_ssl_verification = config_ssl_verification

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

    This ensures that SSL certificates are verified.

    """
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)

    if self._config_ssl_verification:
      self._config_ssl_verification(ctx, self._logger)

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

    self.sock = httplib.FakeSocket(sock, ssl)


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

    @param logger: Logging object
    @type config_ssl_verification: callable
    @param config_ssl_verification: Function to configure SSL context for
                                    certificate verification

    """
    urllib2.HTTPSHandler.__init__(self)
    self._logger = logger
    self._config_ssl_verification = config_ssl_verification

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

    This wrapper is necessary provide a compatible API to urllib2.

    """
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
    conn.Setup(self._logger, self._config_ssl_verification)
    return conn

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

    Called by urllib2.

    """
    return self.do_open(self._CreateHttpsConnection, req)


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

    """
    urllib2.Request.__init__(self, url, data=data, headers=headers)
    self._method = method

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

    """
    return self._method


David Knowles's avatar
David Knowles committed
282
283
284
285
286
class GanetiRapiClient(object):
  """Ganeti RAPI client.

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

289
290
291
292
  def __init__(self, host, port=GANETI_RAPI_PORT,
               username=None, password=None,
               config_ssl_verification=None, ignore_proxy=False,
               logger=logging):
David Knowles's avatar
David Knowles committed
293
294
    """Constructor.

295
296
    @type host: string
    @param host: the ganeti cluster master to interact with
David Knowles's avatar
David Knowles committed
297
    @type port: int
298
299
    @param port: the port on which the RAPI is running (default is 5080)
    @type username: string
David Knowles's avatar
David Knowles committed
300
    @param username: the username to connect with
301
    @type password: string
David Knowles's avatar
David Knowles committed
302
    @param password: the password to connect with
303
304
305
306
307
308
    @type config_ssl_verification: callable
    @param config_ssl_verification: Function to configure SSL context for
                                    certificate verification
    @type ignore_proxy: bool
    @param ignore_proxy: Whether to ignore proxy settings
    @param logger: Logging object
David Knowles's avatar
David Knowles committed
309
310

    """
311
    self._host = host
David Knowles's avatar
David Knowles committed
312
    self._port = port
313
    self._logger = logger
David Knowles's avatar
David Knowles committed
314

315
    self._base_url = "https://%s:%s" % (host, port)
David Knowles's avatar
David Knowles committed
316

317
318
319
320
321
322
323
324
325
326
327
328
329
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]

    if username is not None:
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
      pwmgr.add_password(None, self._base_url, username, password)
      handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
    elif password:
      raise Error("Specified password without username")

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

    self._http = urllib2.build_opener(*handlers) # pylint: disable-msg=W0142
David Knowles's avatar
David Knowles committed
330

331
332
333
334
335
    self._headers = {
      "Accept": HTTP_APP_JSON,
      "Content-type": HTTP_APP_JSON,
      "User-Agent": self.USER_AGENT,
      }
David Knowles's avatar
David Knowles committed
336

337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
  @staticmethod
  def _EncodeQuery(query):
    """Encode query values for RAPI URL.

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

    """
    result = []

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

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

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

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

    return result

365
  def _SendRequest(self, method, path, query, content):
David Knowles's avatar
David Knowles committed
366
367
368
369
370
    """Sends an HTTP request.

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

371
    @type method: string
David Knowles's avatar
David Knowles committed
372
    @param method: HTTP method to use
373
    @type path: string
David Knowles's avatar
David Knowles committed
374
375
376
377
378
379
380
381
382
    @param path: HTTP URL path
    @type query: list of two-tuples
    @param query: query arguments to pass to urllib.urlencode
    @type content: str or None
    @param content: HTTP body content

    @rtype: str
    @return: JSON-Decoded response

David Knowles's avatar
David Knowles committed
383
    @raises CertificateError: If an invalid SSL certificate is found
David Knowles's avatar
David Knowles committed
384
385
386
    @raises GanetiApiError: If an invalid response is returned

    """
387
388
    assert path.startswith("/")

David Knowles's avatar
David Knowles committed
389
    if content:
390
391
392
      encoded_content = self._json_encoder.encode(content)
    else:
      encoded_content = None
David Knowles's avatar
David Knowles committed
393

394
395
396
397
    # Build URL
    url = [self._base_url, path]
    if query:
      url.append("?")
398
      url.append(urllib.urlencode(self._EncodeQuery(query)))
399

400
    req = _RapiRequest(method, "".join(url), self._headers, encoded_content)
401

David Knowles's avatar
David Knowles committed
402
    try:
403
      resp = self._http.open(req)
404
      encoded_response_content = resp.read()
405
406
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
      raise CertificateError("SSL issue: %s" % err)
David Knowles's avatar
David Knowles committed
407

408
409
410
411
    if encoded_response_content:
      response_content = simplejson.loads(encoded_response_content)
    else:
      response_content = None
David Knowles's avatar
David Knowles committed
412
413

    # TODO: Are there other status codes that are valid? (redirect?)
414
    if resp.code != HTTP_OK:
415
      if isinstance(response_content, dict):
David Knowles's avatar
David Knowles committed
416
        msg = ("%s %s: %s" %
417
418
419
               (response_content["code"],
                response_content["message"],
                response_content["explain"]))
David Knowles's avatar
David Knowles committed
420
      else:
421
422
        msg = str(response_content)

423
      raise GanetiApiError(msg, code=resp.code)
David Knowles's avatar
David Knowles committed
424

425
    return response_content
David Knowles's avatar
David Knowles committed
426
427

  def GetVersion(self):
428
    """Gets the Remote API version running on the cluster.
David Knowles's avatar
David Knowles committed
429
430

    @rtype: int
David Knowles's avatar
David Knowles committed
431
    @return: Ganeti Remote API version
David Knowles's avatar
David Knowles committed
432
433

    """
434
    return self._SendRequest(HTTP_GET, "/version", None, None)
David Knowles's avatar
David Knowles committed
435
436
437
438
439
440
441
442

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

    @rtype: list of str
    @return: operating systems

    """
443
444
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
445
446
447
448
449
450
451
452

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

    @rtype: dict
    @return: information about the cluster

    """
453
454
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
455
456
457
458
459
460
461
462

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

    @rtype: list of str
    @return: cluster tags

    """
463
464
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481

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

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

    @rtype: int
    @return: job id

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

482
483
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
484
485
486
487
488
489
490
491
492
493
494
495
496
497

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

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

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

498
499
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514

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

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

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

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

515
516
517
    instances = self._SendRequest(HTTP_GET,
                                  "/%s/instances" % GANETI_RAPI_VERSION,
                                  query, None)
David Knowles's avatar
David Knowles committed
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
    if bulk:
      return instances
    else:
      return [i["id"] for i in instances]

  def GetInstanceInfo(self, instance):
    """Gets information about an instance.

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

    @rtype: dict
    @return: info about the instance

    """
533
534
535
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s" %
                              (GANETI_RAPI_VERSION, instance)), None, None)
David Knowles's avatar
David Knowles committed
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551

  def CreateInstance(self, dry_run=False):
    """Creates a new instance.

    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    # TODO: Pass arguments needed to actually create an instance.
    query = []
    if dry_run:
      query.append(("dry-run", 1))

552
553
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
554
555
556
557
558
559
560

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

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

561
562
563
    @rtype: int
    @return: job id

David Knowles's avatar
David Knowles committed
564
565
566
567
568
    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

569
570
571
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
572
573
574
575
576
577
578
579
580
581
582

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

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

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

    """
583
584
585
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), None, None)
David Knowles's avatar
David Knowles committed
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604

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

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

    @rtype: int
    @return: job id

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

605
606
607
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623

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

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

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

624
625
626
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650

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

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

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

651
652
653
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reboot" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
654
655
656
657
658
659
660
661
662
663
664
665
666
667

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

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

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

668
669
670
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/shutdown" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
671
672
673
674
675
676
677
678
679
680
681
682
683
684

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

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

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

685
686
687
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/startup" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702

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

    @type instance: str
    @param instance: the instance to reinstall
    @type os: str
    @param os: the os to reinstall
    @type no_startup: bool
    @param no_startup: whether to start the instance automatically

    """
    query = [("os", os)]
    if no_startup:
      query.append(("nostartup", 1))
703
704
705
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reinstall" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
706

707
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
708
                           remote_node=None, iallocator=None, dry_run=False):
David Knowles's avatar
David Knowles committed
709
710
711
712
    """Replaces disks on an instance.

    @type instance: str
    @param instance: instance whose disks to replace
713
714
    @type disks: list of ints
    @param disks: Indexes of disks to replace
David Knowles's avatar
David Knowles committed
715
    @type mode: str
716
    @param mode: replacement mode to use (defaults to replace_auto)
David Knowles's avatar
David Knowles committed
717
718
    @type remote_node: str or None
    @param remote_node: new secondary node to use (for use with
719
        replace_new_secondary mode)
David Knowles's avatar
David Knowles committed
720
721
    @type iallocator: str or None
    @param iallocator: instance allocator plugin to use (for use with
722
                       replace_auto mode)
David Knowles's avatar
David Knowles committed
723
724
725
726
727
728
729
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
730
731
732
    query = [
      ("mode", mode),
      ]
David Knowles's avatar
David Knowles committed
733

734
735
736
737
    if disks:
      query.append(("disks", ",".join(str(idx) for idx in disks)))

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

740
741
742
    if iallocator:
      query.append(("iallocator", iallocator))

David Knowles's avatar
David Knowles committed
743
744
745
746
    if dry_run:
      query.append(("dry-run", 1))

    return self._SendRequest(HTTP_POST,
747
748
                             ("/%s/instances/%s/replace-disks" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
749
750
751
752
753
754
755
756

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

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

    """
757
    return [int(j["id"])
758
759
760
            for j in self._SendRequest(HTTP_GET,
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
                                       None, None)]
David Knowles's avatar
David Knowles committed
761
762
763
764
765
766
767
768
769
770
771

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

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

    @rtype: dict
    @return: job status

    """
772
773
774
    return self._SendRequest(HTTP_GET,
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
                             None, None)
David Knowles's avatar
David Knowles committed
775

776
777
778
779
780
781
782
783
784
785
786
787
788
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
    """Waits for job changes.

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

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

789
790
791
    return self._SendRequest(HTTP_GET,
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
                             None, body)
792

793
794
  def CancelJob(self, job_id, dry_run=False):
    """Cancels a job.
David Knowles's avatar
David Knowles committed
795
796
797
798
799
800
801
802
803
804
805

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

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

806
807
808
    return self._SendRequest(HTTP_DELETE,
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
                             query, None)
David Knowles's avatar
David Knowles committed
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824

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

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

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

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

825
826
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
                              query, None)
David Knowles's avatar
David Knowles committed
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
    if bulk:
      return nodes
    else:
      return [n["id"] for n in nodes]

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

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

    @rtype: dict
    @return: info about the node

    """
842
843
844
    return self._SendRequest(HTTP_GET,
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
                             None, None)
David Knowles's avatar
David Knowles committed
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865

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

    @type node: str
    @param node: node to evacuate
    @type iallocator: str or None
    @param iallocator: instance allocator to use
    @type remote_node: str
    @param remote_node: node to evaucate to
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

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

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

868
    query = []
David Knowles's avatar
David Knowles committed
869
870
871
872
873
874
875
    if iallocator:
      query.append(("iallocator", iallocator))
    if remote_node:
      query.append(("remote_node", remote_node))
    if dry_run:
      query.append(("dry-run", 1))

876
877
878
    return self._SendRequest(HTTP_POST,
                             ("/%s/nodes/%s/evacuate" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899

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

    @type node: str
    @param node: node to migrate
    @type live: bool
    @param live: whether to use live migration
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

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

900
901
902
    return self._SendRequest(HTTP_POST,
                             ("/%s/nodes/%s/migrate" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
903
904
905
906
907
908
909
910
911
912
913

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

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

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

    """
914
915
916
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/role" %
                              (GANETI_RAPI_VERSION, node)), None, None)
David Knowles's avatar
David Knowles committed
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931

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

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

    @rtype: int
    @return: job id

    """
932
933
934
    query = [
      ("force", force),
      ]
935

936
937
938
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/role" %
                              (GANETI_RAPI_VERSION, node)), query, role)
David Knowles's avatar
David Knowles committed
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953

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

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

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

    """
954
955
956
957
    query = [
      ("storage_type", storage_type),
      ("output_fields", output_fields),
      ]
David Knowles's avatar
David Knowles committed
958

959
960
961
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/storage" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
962

963
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
David Knowles's avatar
David Knowles committed
964
965
966
967
968
969
970
971
    """Modifies parameters of storage units on the node.

    @type node: str
    @param node: node whose storage units to modify
    @type storage_type: str
    @param storage_type: storage type whose units to modify
    @type name: str
    @param name: name of the storage unit
972
973
974
    @type allocatable: bool or None
    @param allocatable: Whether to set the "allocatable" flag on the storage
                        unit (None=no modification, True=set, False=unset)
David Knowles's avatar
David Knowles committed
975
976
977
978
979
980

    @rtype: int
    @return: job id

    """
    query = [
981
982
983
984
      ("storage_type", storage_type),
      ("name", name),
      ]

985
986
987
    if allocatable is not None:
      query.append(("allocatable", allocatable))

988
989
990
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/storage/modify" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005

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

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

    @rtype: int
    @return: job id

    """
1006
1007
1008
1009
    query = [
      ("storage_type", storage_type),
      ("name", name),
      ]
David Knowles's avatar
David Knowles committed
1010

1011
1012
1013
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/storage/repair" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024

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

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

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

    """
1025
1026
1027
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), None, None)
David Knowles's avatar
David Knowles committed
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046

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

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

    @rtype: int
    @return: job id

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

1047
1048
1049
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), query, tags)
David Knowles's avatar
David Knowles committed
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068

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

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

    @rtype: int
    @return: job id

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

1069
1070
1071
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), query, None)