Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ ccd6b542

History | View | Annotate | Download (27.3 kB)

1
#
2
#
3

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

    
21

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

    
24
import httplib
25
import urllib2
26
import logging
27
import simplejson
28
import socket
29
import urllib
30
import OpenSSL
31
import distutils.version
32

    
33

    
34
GANETI_RAPI_PORT = 5080
35

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

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

    
58

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

62
  """
63
  pass
64

    
65

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

69
  """
70
  pass
71

    
72

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

76
  """
77
  pass
78

    
79

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

83
  """
84
  pass
85

    
86

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

90
  """
91
  pass
92

    
93

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

97
  """
98
  pass
99

    
100

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

104
  @type x509_name: OpenSSL.crypto.X509Name
105

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

    
116

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

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

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

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

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

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

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

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

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

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

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

163
    @param logger: Logging object
164

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

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

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

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

    
187
    return ok
188

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

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

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

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

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

    
210

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

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

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

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

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

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

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

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

239
    This ensures that SSL certificates are verified.
240

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

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

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

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

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

    
255

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

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

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

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

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

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

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

283
    Called by urllib2.
284

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

    
288

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

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

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

300
    """
301
    return self._method
302

    
303

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
417
      raise GanetiApiError(msg)
418

    
419
    return response_content
420

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

577
    @rtype: int
578
    @return: job id
579

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

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

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

590
    @type instance: str
591
    @param instance: instance to delete tags from
592
    @type tags: list of str
593
    @param tags: tags to delete
594
    @type dry_run: bool
595
    @param dry_run: whether to perform a dry run
596

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

    
602
    return self._SendRequest(HTTP_DELETE, "/2/instances/%s/tags" % instance,
603
                             query)
604

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

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

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

    
628
    return self._SendRequest(HTTP_POST, "/2/instances/%s/reboot" % instance,
629
                             query)
630

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

634
    @type instance: str
635
    @param instance: the instance to shut down
636
    @type dry_run: bool
637
    @param dry_run: whether to perform a dry run
638

639
    """
640
    query = []
641
    if dry_run:
642
      query.append(("dry-run", 1))
643

    
644
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/shutdown" % instance,
645
                             query)
646

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

650
    @type instance: str
651
    @param instance: the instance to start up
652
    @type dry_run: bool
653
    @param dry_run: whether to perform a dry run
654

655
    """
656
    query = []
657
    if dry_run:
658
      query.append(("dry-run", 1))
659

    
660
    return self._SendRequest(HTTP_PUT, "/2/instances/%s/startup" % instance,
661
                             query)
662

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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