Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ a60e3cb0

History | View | Annotate | Download (27.2 kB)

1
#
2
#
3

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

    
21

    
22
"""Ganeti RAPI client."""
23

    
24
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

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

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

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

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

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

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

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

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

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

    
358
  def _MakeUrl(self, path, query=None):
359
    """Constructs the URL to pass to the HTTP client.
360

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

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

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

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

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

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

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

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

397
    """
398
    if content:
399
      content = simplejson.JSONEncoder(sort_keys=True).encode(content)
400

    
401
    url = self._MakeUrl(path, query)
402

    
403
    req = _RapiRequest(method, url, self._headers, content)
404

    
405
    try:
406
      resp = self._http.open(req)
407
      resp_content = resp.read()
408
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
409
      raise CertificateError("SSL issue: %s" % err)
410

    
411
    if resp_content:
412
      resp_content = simplejson.loads(resp_content)
413

    
414
    # TODO: Are there other status codes that are valid? (redirect?)
415
    if resp.code != HTTP_OK:
416
      if isinstance(resp_content, dict):
417
        msg = ("%s %s: %s" %
418
            (resp_content["code"], resp_content["message"],
419
             resp_content["explain"]))
420
      else:
421
        msg = resp_content
422
      raise GanetiApiError(msg)
423

    
424
    return resp_content
425

    
426
  def GetVersion(self):
427
    """Gets the Remote API version running on the cluster.
428

429
    @rtype: int
430
    @return: Ganeti Remote API version
431

432
    """
433
    return self._SendRequest(HTTP_GET, "/version")
434

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

438
    @rtype: list of str
439
    @return: operating systems
440

441
    """
442
    return self._SendRequest(HTTP_GET, "/2/os")
443

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

447
    @rtype: dict
448
    @return: information about the cluster
449

450
    """
451
    return self._SendRequest(HTTP_GET, "/2/info")
452

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

456
    @rtype: list of str
457
    @return: cluster tags
458

459
    """
460
    return self._SendRequest(HTTP_GET, "/2/tags")
461

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

465
    @type tags: list of str
466
    @param tags: tags to add to the cluster
467
    @type dry_run: bool
468
    @param dry_run: whether to perform a dry run
469

470
    @rtype: int
471
    @return: job id
472

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

    
478
    return self._SendRequest(HTTP_PUT, "/2/tags", query)
479

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

483
    @type tags: list of str
484
    @param tags: tags to delete
485
    @type dry_run: bool
486
    @param dry_run: whether to perform a dry run
487

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

    
493
    self._SendRequest(HTTP_DELETE, "/2/tags", query)
494

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

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

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

504
    """
505
    query = []
506
    if bulk:
507
      query.append(("bulk", 1))
508

    
509
    instances = self._SendRequest(HTTP_GET, "/2/instances", query)
510
    if bulk:
511
      return instances
512
    else:
513
      return [i["id"] for i in instances]
514

    
515

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

519
    @type instance: str
520
    @param instance: instance whose info to return
521

522
    @rtype: dict
523
    @return: info about the instance
524

525
    """
526
    return self._SendRequest(HTTP_GET, "/2/instances/%s" % instance)
527

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

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

534
    @rtype: int
535
    @return: job id
536

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

    
543
    return self._SendRequest(HTTP_POST, "/2/instances", query)
544

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

548
    @type instance: str
549
    @param instance: the instance to delete
550

551
    @rtype: int
552
    @return: job id
553

554
    """
555
    query = []
556
    if dry_run:
557
      query.append(("dry-run", 1))
558

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

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

564
    @type instance: str
565
    @param instance: instance whose tags to return
566

567
    @rtype: list of str
568
    @return: tags for the instance
569

570
    """
571
    return self._SendRequest(HTTP_GET, "/2/instances/%s/tags" % instance)
572

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

576
    @type instance: str
577
    @param instance: instance to add tags to
578
    @type tags: list of str
579
    @param tags: tags to add to the instance
580
    @type dry_run: bool
581
    @param dry_run: whether to perform a dry run
582

583
    @rtype: int
584
    @return: job id
585

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

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

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

596
    @type instance: str
597
    @param instance: instance to delete tags from
598
    @type tags: list of str
599
    @param tags: tags to delete
600
    @type dry_run: bool
601
    @param dry_run: whether to perform a dry run
602

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

    
608
    self._SendRequest(HTTP_DELETE, "/2/instances/%s/tags" % instance, query)
609

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

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

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

    
633
    self._SendRequest(HTTP_POST, "/2/instances/%s/reboot" % instance, query)
634

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

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

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

    
648
    self._SendRequest(HTTP_PUT, "/2/instances/%s/shutdown" % instance, query)
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
    self._SendRequest(HTTP_PUT, "/2/instances/%s/startup" % instance, query)
664

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

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

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

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

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

700
    @rtype: int
701
    @return: job id
702

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

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

    
712
    query = [("mode", mode), ("disks", ",".join(disks))]
713

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

    
721
    if dry_run:
722
      query.append(("dry-run", 1))
723

    
724
    return self._SendRequest(HTTP_POST,
725
                             "/2/instances/%s/replace-disks" % instance, query)
726

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

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

733
    """
734
    return [int(j["id"]) for j in self._SendRequest(HTTP_GET, "/2/jobs")]
735

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

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

742
    @rtype: dict
743
    @return: job status
744

745
    """
746
    return self._SendRequest(HTTP_GET, "/2/jobs/%d" % job_id)
747

    
748
  def DeleteJob(self, job_id, dry_run=False):
749
    """Deletes a job.
750

751
    @type job_id: int
752
    @param job_id: id of the job to delete
753
    @type dry_run: bool
754
    @param dry_run: whether to perform a dry run
755

756
    """
757
    query = []
758
    if dry_run:
759
      query.append(("dry-run", 1))
760

    
761
    self._SendRequest(HTTP_DELETE, "/2/jobs/%d" % job_id, query)
762

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

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

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

773
    """
774
    query = []
775
    if bulk:
776
      query.append(("bulk", 1))
777

    
778
    nodes = self._SendRequest(HTTP_GET, "/2/nodes", query)
779
    if bulk:
780
      return nodes
781
    else:
782
      return [n["id"] for n in nodes]
783

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

787
    @type node: str
788
    @param node: node whose info to return
789

790
    @rtype: dict
791
    @return: info about the node
792

793
    """
794
    return self._SendRequest(HTTP_GET, "/2/nodes/%s" % node)
795

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

800
    @type node: str
801
    @param node: node to evacuate
802
    @type iallocator: str or None
803
    @param iallocator: instance allocator to use
804
    @type remote_node: str
805
    @param remote_node: node to evaucate to
806
    @type dry_run: bool
807
    @param dry_run: whether to perform a dry run
808

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

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

814
    """
815
    query = []
816
    if iallocator and remote_node:
817
      raise GanetiApiError("Only one of iallocator or remote_node can be used.")
818

    
819
    if iallocator:
820
      query.append(("iallocator", iallocator))
821
    if remote_node:
822
      query.append(("remote_node", remote_node))
823
    if dry_run:
824
      query.append(("dry-run", 1))
825

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

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

831
    @type node: str
832
    @param node: node to migrate
833
    @type live: bool
834
    @param live: whether to use live migration
835
    @type dry_run: bool
836
    @param dry_run: whether to perform a dry run
837

838
    @rtype: int
839
    @return: job id
840

841
    """
842
    query = []
843
    if live:
844
      query.append(("live", 1))
845
    if dry_run:
846
      query.append(("dry-run", 1))
847

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

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

853
    @type node: str
854
    @param node: node whose role to return
855

856
    @rtype: str
857
    @return: the current role for a node
858

859
    """
860
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/role" % node)
861

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

865
    @type node: str
866
    @param node: the node whose role to set
867
    @type role: str
868
    @param role: the role to set for the node
869
    @type force: bool
870
    @param force: whether to force the role change
871

872
    @rtype: int
873
    @return: job id
874

875
    @raise InvalidNodeRole: If an invalid node role is specified
876

877
    """
878
    if role not in VALID_NODE_ROLES:
879
      raise InvalidNodeRole("%s is not a valid node role.", role)
880

    
881
    query = [("force", force)]
882
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/role" % node, query,
883
                             content=role)
884

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

888
    @type node: str
889
    @param node: the node whose storage units to return
890
    @type storage_type: str
891
    @param storage_type: storage type whose units to return
892
    @type output_fields: str
893
    @param output_fields: storage type fields to return
894

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

898
    @raise InvalidStorageType: If an invalid storage type is specified
899

900
    """
901
    # TODO: Add default for storage_type & output_fields
902
    if storage_type not in VALID_STORAGE_TYPES:
903
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
904

    
905
    query = [("storage_type", storage_type), ("output_fields", output_fields)]
906
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/storage" % node, query)
907

    
908
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=True):
909
    """Modifies parameters of storage units on the node.
910

911
    @type node: str
912
    @param node: node whose storage units to modify
913
    @type storage_type: str
914
    @param storage_type: storage type whose units to modify
915
    @type name: str
916
    @param name: name of the storage unit
917
    @type allocatable: bool
918
    @param allocatable: TODO: Document me
919

920
    @rtype: int
921
    @return: job id
922

923
    @raise InvalidStorageType: If an invalid storage type is specified
924

925
    """
926
    if storage_type not in VALID_STORAGE_TYPES:
927
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
928

    
929
    query = [
930
        ("storage_type", storage_type), ("name", name),
931
        ("allocatable", allocatable)
932
        ]
933
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/storage/modify" % node,
934
                             query)
935

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

939
    @type node: str
940
    @param node: node whose storage units to repair
941
    @type storage_type: str
942
    @param storage_type: storage type to repair
943
    @type name: str
944
    @param name: name of the storage unit to repair
945

946
    @rtype: int
947
    @return: job id
948

949
    @raise InvalidStorageType: If an invalid storage type is specified
950

951
    """
952
    if storage_type not in VALID_STORAGE_TYPES:
953
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
954

    
955
    query = [("storage_type", storage_type), ("name", name)]
956
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/storage/repair" % node,
957
                             query)
958

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

962
    @type node: str
963
    @param node: node whose tags to return
964

965
    @rtype: list of str
966
    @return: tags for the node
967

968
    """
969
    return self._SendRequest(HTTP_GET, "/2/nodes/%s/tags" % node)
970

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

974
    @type node: str
975
    @param node: node to add tags to
976
    @type tags: list of str
977
    @param tags: tags to add to the node
978
    @type dry_run: bool
979
    @param dry_run: whether to perform a dry run
980

981
    @rtype: int
982
    @return: job id
983

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

    
989
    return self._SendRequest(HTTP_PUT, "/2/nodes/%s/tags" % node, query,
990
                             content=tags)
991

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

995
    @type node: str
996
    @param node: node to remove tags from
997
    @type tags: list of str
998
    @param tags: tags to remove from the node
999
    @type dry_run: bool
1000
    @param dry_run: whether to perform a dry run
1001

1002
    @rtype: int
1003
    @return: job id
1004

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

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