Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ 9279e986

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

    
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._version = None
337

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

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

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

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

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

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

    
360
  def _MakeUrl(self, path, query=None, prepend_version=True):
361
    """Constructs the URL to pass to the HTTP client.
362

363
    @type path: str
364
    @param path: HTTP URL path
365
    @type query: list of two-tuples
366
    @param query: query arguments to pass to urllib.urlencode
367
    @type prepend_version: bool
368
    @param prepend_version: whether to automatically fetch and prepend the
369
        Ganeti RAPI version to the URL path
370

371
    @rtype:  str
372
    @return: URL path
373

374
    """
375
    if prepend_version:
376
      path = "/%d%s" % (self.GetVersion(), path)
377

    
378
    return "https://%(host)s:%(port)d%(path)s?%(query)s" % {
379
        "host": self._host,
380
        "port": self._port,
381
        "path": path,
382
        "query": urllib.urlencode(query or [])}
383

    
384
  def _SendRequest(self, method, path, query=None, content=None,
385
                   prepend_version=True):
386
    """Sends an HTTP request.
387

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

391
    @type method: str
392
    @param method: HTTP method to use
393
    @type path: str
394
    @param path: HTTP URL path
395
    @type query: list of two-tuples
396
    @param query: query arguments to pass to urllib.urlencode
397
    @type content: str or None
398
    @param content: HTTP body content
399
    @type prepend_version: bool
400
    @param prepend_version: whether to automatically fetch and prepend the
401
        Ganeti RAPI version to the URL path
402

403
    @rtype: str
404
    @return: JSON-Decoded response
405

406
    @raises CertificateError: If an invalid SSL certificate is found
407
    @raises GanetiApiError: If an invalid response is returned
408

409
    """
410
    if content:
411
      content = simplejson.JSONEncoder(sort_keys=True).encode(content)
412

    
413
    url = self._MakeUrl(path, query, prepend_version)
414

    
415
    req = _RapiRequest(method, url, self._headers, content)
416

    
417
    try:
418
      resp = self._http.open(req)
419
      resp_content = resp.read()
420
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
421
      raise CertificateError("SSL issue: %s" % err)
422

    
423
    if resp_content:
424
      resp_content = simplejson.loads(resp_content)
425

    
426
    # TODO: Are there other status codes that are valid? (redirect?)
427
    if resp.code != HTTP_OK:
428
      if isinstance(resp_content, dict):
429
        msg = ("%s %s: %s" %
430
            (resp_content["code"], resp_content["message"],
431
             resp_content["explain"]))
432
      else:
433
        msg = resp_content
434
      raise GanetiApiError(msg)
435

    
436
    return resp_content
437

    
438
  def GetVersion(self):
439
    """Gets the Remote API version running on the cluster.
440

441
    @rtype: int
442
    @return: Ganeti Remote API version
443

444
    """
445
    if self._version is None:
446
      self._version = self._SendRequest(HTTP_GET, "/version",
447
                                        prepend_version=False)
448
    return self._version
449

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

453
    @rtype: list of str
454
    @return: operating systems
455

456
    """
457
    return self._SendRequest(HTTP_GET, "/os")
458

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

462
    @rtype: dict
463
    @return: information about the cluster
464

465
    """
466
    return self._SendRequest(HTTP_GET, "/info")
467

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

471
    @rtype: list of str
472
    @return: cluster tags
473

474
    """
475
    return self._SendRequest(HTTP_GET, "/tags")
476

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

480
    @type tags: list of str
481
    @param tags: tags to add to the cluster
482
    @type dry_run: bool
483
    @param dry_run: whether to perform a dry run
484

485
    @rtype: int
486
    @return: job id
487

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

    
493
    return self._SendRequest(HTTP_PUT, "/tags", query)
494

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

498
    @type tags: list of str
499
    @param tags: tags to delete
500
    @type dry_run: bool
501
    @param dry_run: whether to perform a dry run
502

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

    
508
    self._SendRequest(HTTP_DELETE, "/tags", query)
509

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

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

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

519
    """
520
    query = []
521
    if bulk:
522
      query.append(("bulk", 1))
523

    
524
    instances = self._SendRequest(HTTP_GET, "/instances", query)
525
    if bulk:
526
      return instances
527
    else:
528
      return [i["id"] for i in instances]
529

    
530

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

534
    @type instance: str
535
    @param instance: instance whose info to return
536

537
    @rtype: dict
538
    @return: info about the instance
539

540
    """
541
    return self._SendRequest(HTTP_GET, "/instances/%s" % instance)
542

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

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

549
    @rtype: int
550
    @return: job id
551

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

    
558
    return self._SendRequest(HTTP_POST, "/instances", query)
559

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

563
    @type instance: str
564
    @param instance: the instance to delete
565

566
    @rtype: int
567
    @return: job id
568

569
    """
570
    query = []
571
    if dry_run:
572
      query.append(("dry-run", 1))
573

    
574
    return self._SendRequest(HTTP_DELETE, "/instances/%s" % instance, query)
575

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

579
    @type instance: str
580
    @param instance: instance whose tags to return
581

582
    @rtype: list of str
583
    @return: tags for the instance
584

585
    """
586
    return self._SendRequest(HTTP_GET, "/instances/%s/tags" % instance)
587

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

591
    @type instance: str
592
    @param instance: instance to add tags to
593
    @type tags: list of str
594
    @param tags: tags to add to the instance
595
    @type dry_run: bool
596
    @param dry_run: whether to perform a dry run
597

598
    @rtype: int
599
    @return: job id
600

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

    
606
    return self._SendRequest(HTTP_PUT, "/instances/%s/tags" % instance, query)
607

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

611
    @type instance: str
612
    @param instance: instance to delete tags from
613
    @type tags: list of str
614
    @param tags: tags to delete
615
    @type dry_run: bool
616
    @param dry_run: whether to perform a dry run
617

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

    
623
    self._SendRequest(HTTP_DELETE, "/instances/%s/tags" % instance, query)
624

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

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

639
    """
640
    query = []
641
    if reboot_type:
642
      query.append(("type", reboot_type))
643
    if ignore_secondaries is not None:
644
      query.append(("ignore_secondaries", ignore_secondaries))
645
    if dry_run:
646
      query.append(("dry-run", 1))
647

    
648
    self._SendRequest(HTTP_POST, "/instances/%s/reboot" % instance, query)
649

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

653
    @type instance: str
654
    @param instance: the instance to shut down
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, "/instances/%s/shutdown" % instance, query)
664

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

668
    @type instance: str
669
    @param instance: the instance to start up
670
    @type dry_run: bool
671
    @param dry_run: whether to perform a dry run
672

673
    """
674
    query = []
675
    if dry_run:
676
      query.append(("dry-run", 1))
677

    
678
    self._SendRequest(HTTP_PUT, "/instances/%s/startup" % instance, query)
679

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

683
    @type instance: str
684
    @param instance: the instance to reinstall
685
    @type os: str
686
    @param os: the os to reinstall
687
    @type no_startup: bool
688
    @param no_startup: whether to start the instance automatically
689

690
    """
691
    query = [("os", os)]
692
    if no_startup:
693
      query.append(("nostartup", 1))
694
    self._SendRequest(HTTP_POST, "/instances/%s/reinstall" % instance, query)
695

    
696
  def ReplaceInstanceDisks(self, instance, disks, mode="replace_auto",
697
                           remote_node=None, iallocator="hail", dry_run=False):
698
    """Replaces disks on an instance.
699

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

715
    @rtype: int
716
    @return: job id
717

718
    @raises InvalidReplacementMode: If an invalid disk replacement mode is given
719
    @raises GanetiApiError: If no secondary node is given with a non-auto
720
        replacement mode is requested.
721

722
    """
723
    if mode not in VALID_REPLACEMENT_MODES:
724
      raise InvalidReplacementMode("%s is not a valid disk replacement mode.",
725
                                   mode)
726

    
727
    query = [("mode", mode), ("disks", ",".join(disks))]
728

    
729
    if mode is REPLACE_DISK_AUTO:
730
      query.append(("iallocator", iallocator))
731
    elif mode is REPLACE_DISK_SECONDARY:
732
      if remote_node is None:
733
        raise GanetiApiError("You must supply a new secondary node.")
734
      query.append(("remote_node", remote_node))
735

    
736
    if dry_run:
737
      query.append(("dry-run", 1))
738

    
739
    return self._SendRequest(HTTP_POST,
740
                             "/instances/%s/replace-disks" % instance, query)
741

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

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

748
    """
749
    return [int(j["id"]) for j in self._SendRequest(HTTP_GET, "/jobs")]
750

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

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

757
    @rtype: dict
758
    @return: job status
759

760
    """
761
    return self._SendRequest(HTTP_GET, "/jobs/%d" % job_id)
762

    
763
  def DeleteJob(self, job_id, dry_run=False):
764
    """Deletes a job.
765

766
    @type job_id: int
767
    @param job_id: id of the job to delete
768
    @type dry_run: bool
769
    @param dry_run: whether to perform a dry run
770

771
    """
772
    query = []
773
    if dry_run:
774
      query.append(("dry-run", 1))
775

    
776
    self._SendRequest(HTTP_DELETE, "/jobs/%d" % job_id, query)
777

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

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

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

788
    """
789
    query = []
790
    if bulk:
791
      query.append(("bulk", 1))
792

    
793
    nodes = self._SendRequest(HTTP_GET, "/nodes", query)
794
    if bulk:
795
      return nodes
796
    else:
797
      return [n["id"] for n in nodes]
798

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

802
    @type node: str
803
    @param node: node whose info to return
804

805
    @rtype: dict
806
    @return: info about the node
807

808
    """
809
    return self._SendRequest(HTTP_GET, "/nodes/%s" % node)
810

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

815
    @type node: str
816
    @param node: node to evacuate
817
    @type iallocator: str or None
818
    @param iallocator: instance allocator to use
819
    @type remote_node: str
820
    @param remote_node: node to evaucate to
821
    @type dry_run: bool
822
    @param dry_run: whether to perform a dry run
823

824
    @rtype: int
825
    @return: job id
826

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

829
    """
830
    query = []
831
    if iallocator and remote_node:
832
      raise GanetiApiError("Only one of iallocator or remote_node can be used.")
833

    
834
    if iallocator:
835
      query.append(("iallocator", iallocator))
836
    if remote_node:
837
      query.append(("remote_node", remote_node))
838
    if dry_run:
839
      query.append(("dry-run", 1))
840

    
841
    return self._SendRequest(HTTP_POST, "/nodes/%s/evacuate" % node, query)
842

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

846
    @type node: str
847
    @param node: node to migrate
848
    @type live: bool
849
    @param live: whether to use live migration
850
    @type dry_run: bool
851
    @param dry_run: whether to perform a dry run
852

853
    @rtype: int
854
    @return: job id
855

856
    """
857
    query = []
858
    if live:
859
      query.append(("live", 1))
860
    if dry_run:
861
      query.append(("dry-run", 1))
862

    
863
    return self._SendRequest(HTTP_POST, "/nodes/%s/migrate" % node, query)
864

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

868
    @type node: str
869
    @param node: node whose role to return
870

871
    @rtype: str
872
    @return: the current role for a node
873

874
    """
875
    return self._SendRequest(HTTP_GET, "/nodes/%s/role" % node)
876

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

880
    @type node: str
881
    @param node: the node whose role to set
882
    @type role: str
883
    @param role: the role to set for the node
884
    @type force: bool
885
    @param force: whether to force the role change
886

887
    @rtype: int
888
    @return: job id
889

890
    @raise InvalidNodeRole: If an invalid node role is specified
891

892
    """
893
    if role not in VALID_NODE_ROLES:
894
      raise InvalidNodeRole("%s is not a valid node role.", role)
895

    
896
    query = [("force", force)]
897
    return self._SendRequest(HTTP_PUT, "/nodes/%s/role" % node, query,
898
                             content=role)
899

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

903
    @type node: str
904
    @param node: the node whose storage units to return
905
    @type storage_type: str
906
    @param storage_type: storage type whose units to return
907
    @type output_fields: str
908
    @param output_fields: storage type fields to return
909

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

913
    @raise InvalidStorageType: If an invalid storage type is specified
914

915
    """
916
    # TODO: Add default for storage_type & output_fields
917
    if storage_type not in VALID_STORAGE_TYPES:
918
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
919

    
920
    query = [("storage_type", storage_type), ("output_fields", output_fields)]
921
    return self._SendRequest(HTTP_GET, "/nodes/%s/storage" % node, query)
922

    
923
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=True):
924
    """Modifies parameters of storage units on the node.
925

926
    @type node: str
927
    @param node: node whose storage units to modify
928
    @type storage_type: str
929
    @param storage_type: storage type whose units to modify
930
    @type name: str
931
    @param name: name of the storage unit
932
    @type allocatable: bool
933
    @param allocatable: TODO: Document me
934

935
    @rtype: int
936
    @return: job id
937

938
    @raise InvalidStorageType: If an invalid storage type is specified
939

940
    """
941
    if storage_type not in VALID_STORAGE_TYPES:
942
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
943

    
944
    query = [
945
        ("storage_type", storage_type), ("name", name),
946
        ("allocatable", allocatable)
947
        ]
948
    return self._SendRequest(HTTP_PUT, "/nodes/%s/storage/modify" % node, query)
949

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

953
    @type node: str
954
    @param node: node whose storage units to repair
955
    @type storage_type: str
956
    @param storage_type: storage type to repair
957
    @type name: str
958
    @param name: name of the storage unit to repair
959

960
    @rtype: int
961
    @return: job id
962

963
    @raise InvalidStorageType: If an invalid storage type is specified
964

965
    """
966
    if storage_type not in VALID_STORAGE_TYPES:
967
      raise InvalidStorageType("%s is an invalid storage type.", storage_type)
968

    
969
    query = [("storage_type", storage_type), ("name", name)]
970
    return self._SendRequest(HTTP_PUT, "/nodes/%s/storage/repair" % node, query)
971

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

975
    @type node: str
976
    @param node: node whose tags to return
977

978
    @rtype: list of str
979
    @return: tags for the node
980

981
    """
982
    return self._SendRequest(HTTP_GET, "/nodes/%s/tags" % node)
983

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

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

994
    @rtype: int
995
    @return: job id
996

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

    
1002
    return self._SendRequest(HTTP_PUT, "/nodes/%s/tags" % node, query,
1003
                             content=tags)
1004

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

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

1015
    @rtype: int
1016
    @return: job id
1017

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

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