Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ d3844674

History | View | Annotate | Download (27.6 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 _MakeUrl(self, path, query=None):
360
    """Constructs the URL to pass to the HTTP client.
361

362
    @type path: str
363
    @param path: HTTP URL path
364
    @type query: list of two-tuples
365
    @param query: query arguments to pass to urllib.urlencode
366

367
    @rtype:  str
368
    @return: URL path
369

370
    """
371
    return "https://%(host)s:%(port)d%(path)s?%(query)s" % {
372
        "host": self._host,
373
        "port": self._port,
374
        "path": path,
375
        "query": urllib.urlencode(query or [])}
376

    
377
  def _SendRequest(self, method, path, query=None, content=None):
378
    """Sends an HTTP request.
379

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

383
    @type method: str
384
    @param method: HTTP method to use
385
    @type path: str
386
    @param path: HTTP URL path
387
    @type query: list of two-tuples
388
    @param query: query arguments to pass to urllib.urlencode
389
    @type content: str or None
390
    @param content: HTTP body content
391

392
    @rtype: str
393
    @return: JSON-Decoded response
394

395
    @raises CertificateError: If an invalid SSL certificate is found
396
    @raises GanetiApiError: If an invalid response is returned
397

398
    """
399
    if content:
400
      encoded_content = self._json_encoder.encode(content)
401
    else:
402
      encoded_content = None
403

    
404
    url = self._MakeUrl(path, query)
405

    
406
    req = _RapiRequest(method, url, self._headers, encoded_content)
407

    
408
    try:
409
      resp = self._http.open(req)
410
      encoded_response_content = resp.read()
411
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
412
      raise CertificateError("SSL issue: %s" % err)
413

    
414
    if encoded_response_content:
415
      response_content = simplejson.loads(encoded_response_content)
416
    else:
417
      response_content = None
418

    
419
    # TODO: Are there other status codes that are valid? (redirect?)
420
    if resp.code != HTTP_OK:
421
      if isinstance(response_content, dict):
422
        msg = ("%s %s: %s" %
423
               (response_content["code"],
424
                response_content["message"],
425
                response_content["explain"]))
426
      else:
427
        msg = str(response_content)
428

    
429
      raise GanetiApiError(msg)
430

    
431
    return response_content
432

    
433
  def GetVersion(self):
434
    """Gets the Remote API version running on the cluster.
435

436
    @rtype: int
437
    @return: Ganeti Remote API version
438

439
    """
440
    return self._SendRequest(HTTP_GET, "/version")
441

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

445
    @rtype: list of str
446
    @return: operating systems
447

448
    """
449
    return self._SendRequest(HTTP_GET, "/2/os")
450

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

454
    @rtype: dict
455
    @return: information about the cluster
456

457
    """
458
    return self._SendRequest(HTTP_GET, "/2/info")
459

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

463
    @rtype: list of str
464
    @return: cluster tags
465

466
    """
467
    return self._SendRequest(HTTP_GET, "/2/tags")
468

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

472
    @type tags: list of str
473
    @param tags: tags to add to the cluster
474
    @type dry_run: bool
475
    @param dry_run: whether to perform a dry run
476

477
    @rtype: int
478
    @return: job id
479

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

    
485
    return self._SendRequest(HTTP_PUT, "/2/tags", query)
486

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

490
    @type tags: list of str
491
    @param tags: tags to delete
492
    @type dry_run: bool
493
    @param dry_run: whether to perform a dry run
494

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

    
500
    return self._SendRequest(HTTP_DELETE, "/2/tags", query)
501

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

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

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

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

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

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

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

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

531
    """
532
    return self._SendRequest(HTTP_GET, "/2/instances/%s" % instance)
533

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

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

540
    @rtype: int
541
    @return: job id
542

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

    
549
    return self._SendRequest(HTTP_POST, "/2/instances", query)
550

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

554
    @type instance: str
555
    @param instance: the instance to delete
556

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

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

    
565
    return self._SendRequest(HTTP_DELETE, "/2/instances/%s" % instance, query)
566

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

570
    @type instance: str
571
    @param instance: instance whose tags to return
572

573
    @rtype: list of str
574
    @return: tags for the instance
575

576
    """
577
    return self._SendRequest(HTTP_GET, "/2/instances/%s/tags" % instance)
578

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

582
    @type instance: str
583
    @param instance: instance to add tags to
584
    @type tags: list of str
585
    @param tags: tags to add to the instance
586
    @type dry_run: bool
587
    @param dry_run: whether to perform a dry run
588

589
    @rtype: int
590
    @return: job id
591

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

    
597
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/tags" % instance, query)
598

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

602
    @type instance: str
603
    @param instance: instance to delete tags from
604
    @type tags: list of str
605
    @param tags: tags to delete
606
    @type dry_run: bool
607
    @param dry_run: whether to perform a dry run
608

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

    
614
    return self._SendRequest(HTTP_DELETE, "/2/instances/%s/tags" % instance,
615
                             query)
616

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

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

631
    """
632
    query = []
633
    if reboot_type:
634
      query.append(("type", reboot_type))
635
    if ignore_secondaries is not None:
636
      query.append(("ignore_secondaries", ignore_secondaries))
637
    if dry_run:
638
      query.append(("dry-run", 1))
639

    
640
    return self._SendRequest(HTTP_POST, "/2/instances/%s/reboot" % instance,
641
                             query)
642

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

646
    @type instance: str
647
    @param instance: the instance to shut down
648
    @type dry_run: bool
649
    @param dry_run: whether to perform a dry run
650

651
    """
652
    query = []
653
    if dry_run:
654
      query.append(("dry-run", 1))
655

    
656
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/shutdown" % instance,
657
                             query)
658

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

662
    @type instance: str
663
    @param instance: the instance to start up
664
    @type dry_run: bool
665
    @param dry_run: whether to perform a dry run
666

667
    """
668
    query = []
669
    if dry_run:
670
      query.append(("dry-run", 1))
671

    
672
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/startup" % instance,
673
                             query)
674

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

678
    @type instance: str
679
    @param instance: the instance to reinstall
680
    @type os: str
681
    @param os: the os to reinstall
682
    @type no_startup: bool
683
    @param no_startup: whether to start the instance automatically
684

685
    """
686
    query = [("os", os)]
687
    if no_startup:
688
      query.append(("nostartup", 1))
689
    return self._SendRequest(HTTP_POST, "/2/instances/%s/reinstall" % instance,
690
                             query)
691

    
692
  def ReplaceInstanceDisks(self, instance, disks, mode="replace_auto",
693
                           remote_node=None, iallocator="hail", dry_run=False):
694
    """Replaces disks on an instance.
695

696
    @type instance: str
697
    @param instance: instance whose disks to replace
698
    @type disks: list of str
699
    @param disks: disks to replace
700
    @type mode: str
701
    @param mode: replacement mode to use. defaults to replace_auto
702
    @type remote_node: str or None
703
    @param remote_node: new secondary node to use (for use with
704
        replace_new_secondary mdoe)
705
    @type iallocator: str or None
706
    @param iallocator: instance allocator plugin to use (for use with
707
        replace_auto mdoe).  default is hail
708
    @type dry_run: bool
709
    @param dry_run: whether to perform a dry run
710

711
    @rtype: int
712
    @return: job id
713

714
    @raises InvalidReplacementMode: If an invalid disk replacement mode is given
715
    @raises GanetiApiError: If no secondary node is given with a non-auto
716
        replacement mode is requested.
717

718
    """
719
    if mode not in VALID_REPLACEMENT_MODES:
720
      raise InvalidReplacementMode("%s is not a valid disk replacement mode.",
721
                                   mode)
722

    
723
    query = [("mode", mode), ("disks", ",".join(disks))]
724

    
725
    if mode is REPLACE_DISK_AUTO:
726
      query.append(("iallocator", iallocator))
727
    elif mode is REPLACE_DISK_SECONDARY:
728
      if remote_node is None:
729
        raise GanetiApiError("You must supply a new secondary node.")
730
      query.append(("remote_node", remote_node))
731

    
732
    if dry_run:
733
      query.append(("dry-run", 1))
734

    
735
    return self._SendRequest(HTTP_POST,
736
                             "/2/instances/%s/replace-disks" % instance, query)
737

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

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

744
    """
745
    return [int(j["id"]) for j in self._SendRequest(HTTP_GET, "/2/jobs")]
746

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

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

753
    @rtype: dict
754
    @return: job status
755

756
    """
757
    return self._SendRequest(HTTP_GET, "/2/jobs/%d" % job_id)
758

    
759
  def DeleteJob(self, job_id, dry_run=False):
760
    """Deletes a job.
761

762
    @type job_id: int
763
    @param job_id: id of the job to delete
764
    @type dry_run: bool
765
    @param dry_run: whether to perform a dry run
766

767
    """
768
    query = []
769
    if dry_run:
770
      query.append(("dry-run", 1))
771

    
772
    return self._SendRequest(HTTP_DELETE, "/2/jobs/%d" % job_id, query)
773

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

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

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

784
    """
785
    query = []
786
    if bulk:
787
      query.append(("bulk", 1))
788

    
789
    nodes = self._SendRequest(HTTP_GET, "/2/nodes", query)
790
    if bulk:
791
      return nodes
792
    else:
793
      return [n["id"] for n in nodes]
794

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

798
    @type node: str
799
    @param node: node whose info to return
800

801
    @rtype: dict
802
    @return: info about the node
803

804
    """
805
    return self._SendRequest(HTTP_GET, "/2/nodes/%s" % node)
806

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

811
    @type node: str
812
    @param node: node to evacuate
813
    @type iallocator: str or None
814
    @param iallocator: instance allocator to use
815
    @type remote_node: str
816
    @param remote_node: node to evaucate to
817
    @type dry_run: bool
818
    @param dry_run: whether to perform a dry run
819

820
    @rtype: int
821
    @return: job id
822

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

825
    """
826
    query = []
827
    if iallocator and remote_node:
828
      raise GanetiApiError("Only one of iallocator or remote_node can be used.")
829

    
830
    if iallocator:
831
      query.append(("iallocator", iallocator))
832
    if remote_node:
833
      query.append(("remote_node", remote_node))
834
    if dry_run:
835
      query.append(("dry-run", 1))
836

    
837
    return self._SendRequest(HTTP_POST, "/2/nodes/%s/evacuate" % node, query)
838

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

842
    @type node: str
843
    @param node: node to migrate
844
    @type live: bool
845
    @param live: whether to use live migration
846
    @type dry_run: bool
847
    @param dry_run: whether to perform a dry run
848

849
    @rtype: int
850
    @return: job id
851

852
    """
853
    query = []
854
    if live:
855
      query.append(("live", 1))
856
    if dry_run:
857
      query.append(("dry-run", 1))
858

    
859
    return self._SendRequest(HTTP_POST, "/2/nodes/%s/migrate" % node, query)
860

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

864
    @type node: str
865
    @param node: node whose role to return
866

867
    @rtype: str
868
    @return: the current role for a node
869

870
    """
871
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/role" % node)
872

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

876
    @type node: str
877
    @param node: the node whose role to set
878
    @type role: str
879
    @param role: the role to set for the node
880
    @type force: bool
881
    @param force: whether to force the role change
882

883
    @rtype: int
884
    @return: job id
885

886
    @raise InvalidNodeRole: If an invalid node role is specified
887

888
    """
889
    if role not in VALID_NODE_ROLES:
890
      raise InvalidNodeRole("%s is not a valid node role.", role)
891

    
892
    query = [("force", force)]
893
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/role" % node, query,
894
                             content=role)
895

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

899
    @type node: str
900
    @param node: the node whose storage units to return
901
    @type storage_type: str
902
    @param storage_type: storage type whose units to return
903
    @type output_fields: str
904
    @param output_fields: storage type fields to return
905

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

909
    @raise InvalidStorageType: If an invalid storage type is specified
910

911
    """
912
    # TODO: Add default for storage_type & output_fields
913
    if storage_type not in VALID_STORAGE_TYPES:
914
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
915

    
916
    query = [("storage_type", storage_type), ("output_fields", output_fields)]
917
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/storage" % node, query)
918

    
919
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=True):
920
    """Modifies parameters of storage units on the node.
921

922
    @type node: str
923
    @param node: node whose storage units to modify
924
    @type storage_type: str
925
    @param storage_type: storage type whose units to modify
926
    @type name: str
927
    @param name: name of the storage unit
928
    @type allocatable: bool
929
    @param allocatable: TODO: Document me
930

931
    @rtype: int
932
    @return: job id
933

934
    @raise InvalidStorageType: If an invalid storage type is specified
935

936
    """
937
    if storage_type not in VALID_STORAGE_TYPES:
938
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
939

    
940
    query = [
941
        ("storage_type", storage_type), ("name", name),
942
        ("allocatable", allocatable)
943
        ]
944
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/storage/modify" % node,
945
                             query)
946

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

950
    @type node: str
951
    @param node: node whose storage units to repair
952
    @type storage_type: str
953
    @param storage_type: storage type to repair
954
    @type name: str
955
    @param name: name of the storage unit to repair
956

957
    @rtype: int
958
    @return: job id
959

960
    @raise InvalidStorageType: If an invalid storage type is specified
961

962
    """
963
    if storage_type not in VALID_STORAGE_TYPES:
964
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
965

    
966
    query = [("storage_type", storage_type), ("name", name)]
967
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/storage/repair" % node,
968
                             query)
969

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

973
    @type node: str
974
    @param node: node whose tags to return
975

976
    @rtype: list of str
977
    @return: tags for the node
978

979
    """
980
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/tags" % node)
981

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

985
    @type node: str
986
    @param node: node to add tags to
987
    @type tags: list of str
988
    @param tags: tags to add to the node
989
    @type dry_run: bool
990
    @param dry_run: whether to perform a dry run
991

992
    @rtype: int
993
    @return: job id
994

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

    
1000
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/tags" % node, query,
1001
                             content=tags)
1002

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

1006
    @type node: str
1007
    @param node: node to remove tags from
1008
    @type tags: list of str
1009
    @param tags: tags to remove from the node
1010
    @type dry_run: bool
1011
    @param dry_run: whether to perform a dry run
1012

1013
    @rtype: int
1014
    @return: job id
1015

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

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