Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ 768747ed

History | View | Annotate | Download (27.7 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
import httplib
25
import urllib2
26
import logging
27
import simplejson
28
import socket
29
import urllib
30
import OpenSSL
31
import distutils.version
32

    
33

    
34
GANETI_RAPI_PORT = 5080
35

    
36
HTTP_DELETE = "DELETE"
37
HTTP_GET = "GET"
38
HTTP_PUT = "PUT"
39
HTTP_POST = "POST"
40
HTTP_OK = 200
41
HTTP_APP_JSON = "application/json"
42

    
43
REPLACE_DISK_PRI = "replace_on_primary"
44
REPLACE_DISK_SECONDARY = "replace_on_secondary"
45
REPLACE_DISK_CHG = "replace_new_secondary"
46
REPLACE_DISK_AUTO = "replace_auto"
47
VALID_REPLACEMENT_MODES = frozenset([
48
  REPLACE_DISK_PRI,
49
  REPLACE_DISK_SECONDARY,
50
  REPLACE_DISK_CHG,
51
  REPLACE_DISK_AUTO,
52
  ])
53
VALID_NODE_ROLES = frozenset([
54
  "drained", "master", "master-candidate", "offline", "regular",
55
  ])
56
VALID_STORAGE_TYPES = frozenset(["file", "lvm-pv", "lvm-vg"])
57

    
58

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

62
  """
63
  pass
64

    
65

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

69
  """
70
  pass
71

    
72

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

76
  """
77
  pass
78

    
79

    
80
class InvalidReplacementMode(Error):
81
  """Raised when an invalid disk replacement mode is attempted.
82

83
  """
84
  pass
85

    
86

    
87
class InvalidStorageType(Error):
88
  """Raised when an invalid storage type is used.
89

90
  """
91
  pass
92

    
93

    
94
class InvalidNodeRole(Error):
95
  """Raised when an invalid node role is used.
96

97
  """
98
  pass
99

    
100

    
101
def FormatX509Name(x509_name):
102
  """Formats an X509 name.
103

104
  @type x509_name: OpenSSL.crypto.X509Name
105

106
  """
107
  try:
108
    # Only supported in pyOpenSSL 0.7 and above
109
    get_components_fn = x509_name.get_components
110
  except AttributeError:
111
    return repr(x509_name)
112
  else:
113
    return "".join("/%s=%s" % (name, value)
114
                   for name, value in get_components_fn())
115

    
116

    
117
class CertAuthorityVerify:
118
  """Certificate verificator for SSL context.
119

120
  Configures SSL context to verify server's certificate.
121

122
  """
123
  _CAPATH_MINVERSION = "0.9"
124
  _DEFVFYPATHS_MINVERSION = "0.9"
125

    
126
  _PYOPENSSL_VERSION = OpenSSL.__version__
127
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
128

    
129
  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
130
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
131

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

135
    @type cafile: string
136
    @param cafile: In which file we can find the certificates
137
    @type capath: string
138
    @param capath: In which directory we can find the certificates
139
    @type use_default_verify_paths: bool
140
    @param use_default_verify_paths: Whether the platform provided CA
141
                                     certificates are to be used for
142
                                     verification purposes
143

144
    """
145
    self._cafile = cafile
146
    self._capath = capath
147
    self._use_default_verify_paths = use_default_verify_paths
148

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

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

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

163
    @param logger: Logging object
164

165
    """
166
    if ok:
167
      log_fn = logger.debug
168
    else:
169
      log_fn = logger.error
170

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

    
175
    if not ok:
176
      try:
177
        # Only supported in pyOpenSSL 0.7 and above
178
        # pylint: disable-msg=E1101
179
        fn = OpenSSL.crypto.X509_verify_cert_error_string
180
      except AttributeError:
181
        errmsg = ""
182
      else:
183
        errmsg = ":%s" % fn(errnum)
184

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

    
187
    return ok
188

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

192
    @type ctx: OpenSSL.SSL.Context
193
    @param ctx: SSL context
194

195
    """
196
    if self._use_default_verify_paths:
197
      ctx.set_default_verify_paths()
198

    
199
    if self._cafile or self._capath:
200
      if self._SUPPORT_CAPATH:
201
        ctx.load_verify_locations(self._cafile, self._capath)
202
      else:
203
        ctx.load_verify_locations(self._cafile)
204

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

    
210

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

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

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

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

226
    @param logger: Logging object
227
    @type config_ssl_verification: callable
228

229
    """
230
    assert self._logger is None
231
    assert self._config_ssl_verification is None
232

    
233
    self._logger = logger
234
    self._config_ssl_verification = config_ssl_verification
235

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

239
    This ensures that SSL certificates are verified.
240

241
    """
242
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
243

    
244
    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
245
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
246

    
247
    if self._config_ssl_verification:
248
      self._config_ssl_verification(ctx, self._logger)
249

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

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

    
255

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

260
    @param logger: Logging object
261
    @type config_ssl_verification: callable
262
    @param config_ssl_verification: Function to configure SSL context for
263
                                    certificate verification
264

265
    """
266
    urllib2.HTTPSHandler.__init__(self)
267
    self._logger = logger
268
    self._config_ssl_verification = config_ssl_verification
269

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

273
    This wrapper is necessary provide a compatible API to urllib2.
274

275
    """
276
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
277
    conn.Setup(self._logger, self._config_ssl_verification)
278
    return conn
279

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

283
    Called by urllib2.
284

285
    """
286
    return self.do_open(self._CreateHttpsConnection, req)
287

    
288

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

293
    """
294
    urllib2.Request.__init__(self, url, data=data, headers=headers)
295
    self._method = method
296

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

300
    """
301
    return self._method
302

    
303

    
304
class GanetiRapiClient(object):
305
  """Ganeti RAPI client.
306

307
  """
308
  USER_AGENT = "Ganeti RAPI Client"
309
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
310

    
311
  def __init__(self, host, port=GANETI_RAPI_PORT,
312
               username=None, password=None,
313
               config_ssl_verification=None, ignore_proxy=False,
314
               logger=logging):
315
    """Constructor.
316

317
    @type host: string
318
    @param host: the ganeti cluster master to interact with
319
    @type port: int
320
    @param port: the port on which the RAPI is running (default is 5080)
321
    @type username: string
322
    @param username: the username to connect with
323
    @type password: string
324
    @param password: the password to connect with
325
    @type config_ssl_verification: callable
326
    @param config_ssl_verification: Function to configure SSL context for
327
                                    certificate verification
328
    @type ignore_proxy: bool
329
    @param ignore_proxy: Whether to ignore proxy settings
330
    @param logger: Logging object
331

332
    """
333
    self._host = host
334
    self._port = port
335
    self._logger = logger
336

    
337
    self._base_url = "https://%s:%s" % (host, port)
338

    
339
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
340

    
341
    if username is not None:
342
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
343
      pwmgr.add_password(None, self._base_url, username, password)
344
      handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
345
    elif password:
346
      raise Error("Specified password without username")
347

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

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

    
353
    self._headers = {
354
      "Accept": HTTP_APP_JSON,
355
      "Content-type": HTTP_APP_JSON,
356
      "User-Agent": self.USER_AGENT,
357
      }
358

    
359
  def _SendRequest(self, method, path, query, content):
360
    """Sends an HTTP request.
361

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

365
    @type method: string
366
    @param method: HTTP method to use
367
    @type path: string
368
    @param path: HTTP URL path
369
    @type query: list of two-tuples
370
    @param query: query arguments to pass to urllib.urlencode
371
    @type content: str or None
372
    @param content: HTTP body content
373

374
    @rtype: str
375
    @return: JSON-Decoded response
376

377
    @raises CertificateError: If an invalid SSL certificate is found
378
    @raises GanetiApiError: If an invalid response is returned
379

380
    """
381
    assert path.startswith("/")
382

    
383
    if content:
384
      encoded_content = self._json_encoder.encode(content)
385
    else:
386
      encoded_content = None
387

    
388
    # Build URL
389
    url = [self._base_url, path]
390
    if query:
391
      url.append("?")
392
      url.append(urllib.urlencode(query))
393

    
394
    req = _RapiRequest(method, "".join(url), self._headers, encoded_content)
395

    
396
    try:
397
      resp = self._http.open(req)
398
      encoded_response_content = resp.read()
399
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
400
      raise CertificateError("SSL issue: %s" % err)
401

    
402
    if encoded_response_content:
403
      response_content = simplejson.loads(encoded_response_content)
404
    else:
405
      response_content = None
406

    
407
    # TODO: Are there other status codes that are valid? (redirect?)
408
    if resp.code != HTTP_OK:
409
      if isinstance(response_content, dict):
410
        msg = ("%s %s: %s" %
411
               (response_content["code"],
412
                response_content["message"],
413
                response_content["explain"]))
414
      else:
415
        msg = str(response_content)
416

    
417
      raise GanetiApiError(msg)
418

    
419
    return response_content
420

    
421
  def GetVersion(self):
422
    """Gets the Remote API version running on the cluster.
423

424
    @rtype: int
425
    @return: Ganeti Remote API version
426

427
    """
428
    return self._SendRequest(HTTP_GET, "/version", None, None)
429

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

433
    @rtype: list of str
434
    @return: operating systems
435

436
    """
437
    return self._SendRequest(HTTP_GET, "/2/os", None, None)
438

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

442
    @rtype: dict
443
    @return: information about the cluster
444

445
    """
446
    return self._SendRequest(HTTP_GET, "/2/info", None, None)
447

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

451
    @rtype: list of str
452
    @return: cluster tags
453

454
    """
455
    return self._SendRequest(HTTP_GET, "/2/tags", None, None)
456

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

460
    @type tags: list of str
461
    @param tags: tags to add to the cluster
462
    @type dry_run: bool
463
    @param dry_run: whether to perform a dry run
464

465
    @rtype: int
466
    @return: job id
467

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

    
473
    return self._SendRequest(HTTP_PUT, "/2/tags", query, None)
474

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

478
    @type tags: list of str
479
    @param tags: tags to delete
480
    @type dry_run: bool
481
    @param dry_run: whether to perform a dry run
482

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

    
488
    return self._SendRequest(HTTP_DELETE, "/2/tags", query, None)
489

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

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

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

499
    """
500
    query = []
501
    if bulk:
502
      query.append(("bulk", 1))
503

    
504
    instances = self._SendRequest(HTTP_GET, "/2/instances", query, None)
505
    if bulk:
506
      return instances
507
    else:
508
      return [i["id"] for i in instances]
509

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

513
    @type instance: str
514
    @param instance: instance whose info to return
515

516
    @rtype: dict
517
    @return: info about the instance
518

519
    """
520
    return self._SendRequest(HTTP_GET, "/2/instances/%s" % instance, None, None)
521

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

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

528
    @rtype: int
529
    @return: job id
530

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

    
537
    return self._SendRequest(HTTP_POST, "/2/instances", query, None)
538

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

542
    @type instance: str
543
    @param instance: the instance to delete
544

545
    @rtype: int
546
    @return: job id
547

548
    """
549
    query = []
550
    if dry_run:
551
      query.append(("dry-run", 1))
552

    
553
    return self._SendRequest(HTTP_DELETE, "/2/instances/%s" % instance,
554
                             query, None)
555

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

559
    @type instance: str
560
    @param instance: instance whose tags to return
561

562
    @rtype: list of str
563
    @return: tags for the instance
564

565
    """
566
    return self._SendRequest(HTTP_GET, "/2/instances/%s/tags" % instance,
567
                             None, None)
568

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

572
    @type instance: str
573
    @param instance: instance to add tags to
574
    @type tags: list of str
575
    @param tags: tags to add to the instance
576
    @type dry_run: bool
577
    @param dry_run: whether to perform a dry run
578

579
    @rtype: int
580
    @return: job id
581

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

    
587
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/tags" % instance,
588
                             query, None)
589

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

593
    @type instance: str
594
    @param instance: instance to delete tags from
595
    @type tags: list of str
596
    @param tags: tags to delete
597
    @type dry_run: bool
598
    @param dry_run: whether to perform a dry run
599

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

    
605
    return self._SendRequest(HTTP_DELETE, "/2/instances/%s/tags" % instance,
606
                             query, None)
607

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

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

622
    """
623
    query = []
624
    if reboot_type:
625
      query.append(("type", reboot_type))
626
    if ignore_secondaries is not None:
627
      query.append(("ignore_secondaries", ignore_secondaries))
628
    if dry_run:
629
      query.append(("dry-run", 1))
630

    
631
    return self._SendRequest(HTTP_POST, "/2/instances/%s/reboot" % instance,
632
                             query, None)
633

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

637
    @type instance: str
638
    @param instance: the instance to shut down
639
    @type dry_run: bool
640
    @param dry_run: whether to perform a dry run
641

642
    """
643
    query = []
644
    if dry_run:
645
      query.append(("dry-run", 1))
646

    
647
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/shutdown" % instance,
648
                             query, None)
649

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

653
    @type instance: str
654
    @param instance: the instance to start up
655
    @type dry_run: bool
656
    @param dry_run: whether to perform a dry run
657

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

    
663
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/startup" % instance,
664
                             query, None)
665

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

669
    @type instance: str
670
    @param instance: the instance to reinstall
671
    @type os: str
672
    @param os: the os to reinstall
673
    @type no_startup: bool
674
    @param no_startup: whether to start the instance automatically
675

676
    """
677
    query = [("os", os)]
678
    if no_startup:
679
      query.append(("nostartup", 1))
680
    return self._SendRequest(HTTP_POST, "/2/instances/%s/reinstall" % instance,
681
                             query, None)
682

    
683
  def ReplaceInstanceDisks(self, instance, disks, mode="replace_auto",
684
                           remote_node=None, iallocator="hail", dry_run=False):
685
    """Replaces disks on an instance.
686

687
    @type instance: str
688
    @param instance: instance whose disks to replace
689
    @type disks: list of str
690
    @param disks: disks to replace
691
    @type mode: str
692
    @param mode: replacement mode to use. defaults to replace_auto
693
    @type remote_node: str or None
694
    @param remote_node: new secondary node to use (for use with
695
        replace_new_secondary mdoe)
696
    @type iallocator: str or None
697
    @param iallocator: instance allocator plugin to use (for use with
698
        replace_auto mdoe).  default is hail
699
    @type dry_run: bool
700
    @param dry_run: whether to perform a dry run
701

702
    @rtype: int
703
    @return: job id
704

705
    @raises InvalidReplacementMode: If an invalid disk replacement mode is given
706
    @raises GanetiApiError: If no secondary node is given with a non-auto
707
        replacement mode is requested.
708

709
    """
710
    if mode not in VALID_REPLACEMENT_MODES:
711
      raise InvalidReplacementMode("%s is not a valid disk replacement mode.",
712
                                   mode)
713

    
714
    query = [("mode", mode), ("disks", ",".join(disks))]
715

    
716
    if mode is REPLACE_DISK_AUTO:
717
      query.append(("iallocator", iallocator))
718
    elif mode is REPLACE_DISK_SECONDARY:
719
      if remote_node is None:
720
        raise GanetiApiError("You must supply a new secondary node.")
721
      query.append(("remote_node", remote_node))
722

    
723
    if dry_run:
724
      query.append(("dry-run", 1))
725

    
726
    return self._SendRequest(HTTP_POST,
727
                             "/2/instances/%s/replace-disks" % instance,
728
                             query, None)
729

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

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

736
    """
737
    return [int(j["id"])
738
            for j in self._SendRequest(HTTP_GET, "/2/jobs", None, None)]
739

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

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

746
    @rtype: dict
747
    @return: job status
748

749
    """
750
    return self._SendRequest(HTTP_GET, "/2/jobs/%d" % job_id, None, None)
751

    
752
  def DeleteJob(self, job_id, dry_run=False):
753
    """Deletes a job.
754

755
    @type job_id: int
756
    @param job_id: id of the job to delete
757
    @type dry_run: bool
758
    @param dry_run: whether to perform a dry run
759

760
    """
761
    query = []
762
    if dry_run:
763
      query.append(("dry-run", 1))
764

    
765
    return self._SendRequest(HTTP_DELETE, "/2/jobs/%d" % job_id, query, None)
766

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

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

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

777
    """
778
    query = []
779
    if bulk:
780
      query.append(("bulk", 1))
781

    
782
    nodes = self._SendRequest(HTTP_GET, "/2/nodes", query, None)
783
    if bulk:
784
      return nodes
785
    else:
786
      return [n["id"] for n in nodes]
787

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

791
    @type node: str
792
    @param node: node whose info to return
793

794
    @rtype: dict
795
    @return: info about the node
796

797
    """
798
    return self._SendRequest(HTTP_GET, "/2/nodes/%s" % node, None, None)
799

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

804
    @type node: str
805
    @param node: node to evacuate
806
    @type iallocator: str or None
807
    @param iallocator: instance allocator to use
808
    @type remote_node: str
809
    @param remote_node: node to evaucate to
810
    @type dry_run: bool
811
    @param dry_run: whether to perform a dry run
812

813
    @rtype: int
814
    @return: job id
815

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

818
    """
819
    query = []
820
    if iallocator and remote_node:
821
      raise GanetiApiError("Only one of iallocator or remote_node can be used.")
822

    
823
    if iallocator:
824
      query.append(("iallocator", iallocator))
825
    if remote_node:
826
      query.append(("remote_node", remote_node))
827
    if dry_run:
828
      query.append(("dry-run", 1))
829

    
830
    return self._SendRequest(HTTP_POST, "/2/nodes/%s/evacuate" % node,
831
                             query, None)
832

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

836
    @type node: str
837
    @param node: node to migrate
838
    @type live: bool
839
    @param live: whether to use live migration
840
    @type dry_run: bool
841
    @param dry_run: whether to perform a dry run
842

843
    @rtype: int
844
    @return: job id
845

846
    """
847
    query = []
848
    if live:
849
      query.append(("live", 1))
850
    if dry_run:
851
      query.append(("dry-run", 1))
852

    
853
    return self._SendRequest(HTTP_POST, "/2/nodes/%s/migrate" % node,
854
                             query, None)
855

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

859
    @type node: str
860
    @param node: node whose role to return
861

862
    @rtype: str
863
    @return: the current role for a node
864

865
    """
866
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/role" % node, None, None)
867

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

871
    @type node: str
872
    @param node: the node whose role to set
873
    @type role: str
874
    @param role: the role to set for the node
875
    @type force: bool
876
    @param force: whether to force the role change
877

878
    @rtype: int
879
    @return: job id
880

881
    @raise InvalidNodeRole: If an invalid node role is specified
882

883
    """
884
    if role not in VALID_NODE_ROLES:
885
      raise InvalidNodeRole("%s is not a valid node role.", role)
886

    
887
    query = [("force", force)]
888
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/role" % node,
889
                             query, role)
890

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

894
    @type node: str
895
    @param node: the node whose storage units to return
896
    @type storage_type: str
897
    @param storage_type: storage type whose units to return
898
    @type output_fields: str
899
    @param output_fields: storage type fields to return
900

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

904
    @raise InvalidStorageType: If an invalid storage type is specified
905

906
    """
907
    # TODO: Add default for storage_type & output_fields
908
    if storage_type not in VALID_STORAGE_TYPES:
909
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
910

    
911
    query = [("storage_type", storage_type), ("output_fields", output_fields)]
912
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/storage" % node,
913
                             query, None)
914

    
915
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=True):
916
    """Modifies parameters of storage units on the node.
917

918
    @type node: str
919
    @param node: node whose storage units to modify
920
    @type storage_type: str
921
    @param storage_type: storage type whose units to modify
922
    @type name: str
923
    @param name: name of the storage unit
924
    @type allocatable: bool
925
    @param allocatable: TODO: Document me
926

927
    @rtype: int
928
    @return: job id
929

930
    @raise InvalidStorageType: If an invalid storage type is specified
931

932
    """
933
    if storage_type not in VALID_STORAGE_TYPES:
934
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
935

    
936
    query = [
937
        ("storage_type", storage_type), ("name", name),
938
        ("allocatable", allocatable)
939
        ]
940
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/storage/modify" % node,
941
                             query, None)
942

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

946
    @type node: str
947
    @param node: node whose storage units to repair
948
    @type storage_type: str
949
    @param storage_type: storage type to repair
950
    @type name: str
951
    @param name: name of the storage unit to repair
952

953
    @rtype: int
954
    @return: job id
955

956
    @raise InvalidStorageType: If an invalid storage type is specified
957

958
    """
959
    if storage_type not in VALID_STORAGE_TYPES:
960
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
961

    
962
    query = [("storage_type", storage_type), ("name", name)]
963
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/storage/repair" % node,
964
                             query, None)
965

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

969
    @type node: str
970
    @param node: node whose tags to return
971

972
    @rtype: list of str
973
    @return: tags for the node
974

975
    """
976
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/tags" % node, None, None)
977

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

981
    @type node: str
982
    @param node: node to add tags to
983
    @type tags: list of str
984
    @param tags: tags to add to the node
985
    @type dry_run: bool
986
    @param dry_run: whether to perform a dry run
987

988
    @rtype: int
989
    @return: job id
990

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

    
996
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/tags" % node,
997
                             query, tags)
998

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

1002
    @type node: str
1003
    @param node: node to remove tags from
1004
    @type tags: list of str
1005
    @param tags: tags to remove from the node
1006
    @type dry_run: bool
1007
    @param dry_run: whether to perform a dry run
1008

1009
    @rtype: int
1010
    @return: job id
1011

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

    
1017
    return self._SendRequest(HTTP_DELETE, "/2/nodes/%s/tags" % node,
1018
                             query, None)