Statistics
| Branch: | Tag: | Revision:

root / lib / rapi / client.py @ 857705e8

History | View | Annotate | Download (29.5 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
GANETI_RAPI_VERSION = 2
36

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

    
44
REPLACE_DISK_PRI = "replace_on_primary"
45
REPLACE_DISK_SECONDARY = "replace_on_secondary"
46
REPLACE_DISK_CHG = "replace_new_secondary"
47
REPLACE_DISK_AUTO = "replace_auto"
48

    
49
NODE_ROLE_DRAINED = "drained"
50
NODE_ROLE_MASTER_CANDIATE = "master-candidate"
51
NODE_ROLE_MASTER = "master"
52
NODE_ROLE_OFFLINE = "offline"
53
NODE_ROLE_REGULAR = "regular"
54

    
55

    
56
class Error(Exception):
57
  """Base error class for this module.
58

59
  """
60
  pass
61

    
62

    
63
class CertificateError(Error):
64
  """Raised when a problem is found with the SSL certificate.
65

66
  """
67
  pass
68

    
69

    
70
class GanetiApiError(Error):
71
  """Generic error raised from Ganeti API.
72

73
  """
74
  def __init__(self, msg, code=None):
75
    Error.__init__(self, msg)
76
    self.code = code
77

    
78

    
79
def FormatX509Name(x509_name):
80
  """Formats an X509 name.
81

82
  @type x509_name: OpenSSL.crypto.X509Name
83

84
  """
85
  try:
86
    # Only supported in pyOpenSSL 0.7 and above
87
    get_components_fn = x509_name.get_components
88
  except AttributeError:
89
    return repr(x509_name)
90
  else:
91
    return "".join("/%s=%s" % (name, value)
92
                   for name, value in get_components_fn())
93

    
94

    
95
class CertAuthorityVerify:
96
  """Certificate verificator for SSL context.
97

98
  Configures SSL context to verify server's certificate.
99

100
  """
101
  _CAPATH_MINVERSION = "0.9"
102
  _DEFVFYPATHS_MINVERSION = "0.9"
103

    
104
  _PYOPENSSL_VERSION = OpenSSL.__version__
105
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
106

    
107
  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
108
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
109

    
110
  def __init__(self, cafile=None, capath=None, use_default_verify_paths=False):
111
    """Initializes this class.
112

113
    @type cafile: string
114
    @param cafile: In which file we can find the certificates
115
    @type capath: string
116
    @param capath: In which directory we can find the certificates
117
    @type use_default_verify_paths: bool
118
    @param use_default_verify_paths: Whether the platform provided CA
119
                                     certificates are to be used for
120
                                     verification purposes
121

122
    """
123
    self._cafile = cafile
124
    self._capath = capath
125
    self._use_default_verify_paths = use_default_verify_paths
126

    
127
    if self._capath is not None and not self._SUPPORT_CAPATH:
128
      raise Error(("PyOpenSSL %s has no support for a CA directory,"
129
                   " version %s or above is required") %
130
                  (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION))
131

    
132
    if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS:
133
      raise Error(("PyOpenSSL %s has no support for using default verification"
134
                   " paths, version %s or above is required") %
135
                  (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION))
136

    
137
  @staticmethod
138
  def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok):
139
    """Callback for SSL certificate verification.
140

141
    @param logger: Logging object
142

143
    """
144
    if ok:
145
      log_fn = logger.debug
146
    else:
147
      log_fn = logger.error
148

    
149
    log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'",
150
           errdepth, FormatX509Name(cert.get_subject()),
151
           FormatX509Name(cert.get_issuer()))
152

    
153
    if not ok:
154
      try:
155
        # Only supported in pyOpenSSL 0.7 and above
156
        # pylint: disable-msg=E1101
157
        fn = OpenSSL.crypto.X509_verify_cert_error_string
158
      except AttributeError:
159
        errmsg = ""
160
      else:
161
        errmsg = ":%s" % fn(errnum)
162

    
163
      logger.error("verify error:num=%s%s", errnum, errmsg)
164

    
165
    return ok
166

    
167
  def __call__(self, ctx, logger):
168
    """Configures an SSL context to verify certificates.
169

170
    @type ctx: OpenSSL.SSL.Context
171
    @param ctx: SSL context
172

173
    """
174
    if self._use_default_verify_paths:
175
      ctx.set_default_verify_paths()
176

    
177
    if self._cafile or self._capath:
178
      if self._SUPPORT_CAPATH:
179
        ctx.load_verify_locations(self._cafile, self._capath)
180
      else:
181
        ctx.load_verify_locations(self._cafile)
182

    
183
    ctx.set_verify(OpenSSL.SSL.VERIFY_PEER,
184
                   lambda conn, cert, errnum, errdepth, ok: \
185
                     self._VerifySslCertCb(logger, conn, cert,
186
                                           errnum, errdepth, ok))
187

    
188

    
189
class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
190
  """HTTPS Connection handler that verifies the SSL certificate.
191

192
  """
193
  def __init__(self, *args, **kwargs):
194
    """Initializes this class.
195

196
    """
197
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
198
    self._logger = None
199
    self._config_ssl_verification = None
200

    
201
  def Setup(self, logger, config_ssl_verification):
202
    """Sets the SSL verification config function.
203

204
    @param logger: Logging object
205
    @type config_ssl_verification: callable
206

207
    """
208
    assert self._logger is None
209
    assert self._config_ssl_verification is None
210

    
211
    self._logger = logger
212
    self._config_ssl_verification = config_ssl_verification
213

    
214
  def connect(self):
215
    """Connect to the server specified when the object was created.
216

217
    This ensures that SSL certificates are verified.
218

219
    """
220
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
221

    
222
    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
223
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
224

    
225
    if self._config_ssl_verification:
226
      self._config_ssl_verification(ctx, self._logger)
227

    
228
    ssl = OpenSSL.SSL.Connection(ctx, sock)
229
    ssl.connect((self.host, self.port))
230

    
231
    self.sock = httplib.FakeSocket(sock, ssl)
232

    
233

    
234
class _HTTPSHandler(urllib2.HTTPSHandler):
235
  def __init__(self, logger, config_ssl_verification):
236
    """Initializes this class.
237

238
    @param logger: Logging object
239
    @type config_ssl_verification: callable
240
    @param config_ssl_verification: Function to configure SSL context for
241
                                    certificate verification
242

243
    """
244
    urllib2.HTTPSHandler.__init__(self)
245
    self._logger = logger
246
    self._config_ssl_verification = config_ssl_verification
247

    
248
  def _CreateHttpsConnection(self, *args, **kwargs):
249
    """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification.
250

251
    This wrapper is necessary provide a compatible API to urllib2.
252

253
    """
254
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
255
    conn.Setup(self._logger, self._config_ssl_verification)
256
    return conn
257

    
258
  def https_open(self, req):
259
    """Creates HTTPS connection.
260

261
    Called by urllib2.
262

263
    """
264
    return self.do_open(self._CreateHttpsConnection, req)
265

    
266

    
267
class _RapiRequest(urllib2.Request):
268
  def __init__(self, method, url, headers, data):
269
    """Initializes this class.
270

271
    """
272
    urllib2.Request.__init__(self, url, data=data, headers=headers)
273
    self._method = method
274

    
275
  def get_method(self):
276
    """Returns the HTTP request method.
277

278
    """
279
    return self._method
280

    
281

    
282
class GanetiRapiClient(object):
283
  """Ganeti RAPI client.
284

285
  """
286
  USER_AGENT = "Ganeti RAPI Client"
287
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
288

    
289
  def __init__(self, host, port=GANETI_RAPI_PORT,
290
               username=None, password=None,
291
               config_ssl_verification=None, ignore_proxy=False,
292
               logger=logging):
293
    """Constructor.
294

295
    @type host: string
296
    @param host: the ganeti cluster master to interact with
297
    @type port: int
298
    @param port: the port on which the RAPI is running (default is 5080)
299
    @type username: string
300
    @param username: the username to connect with
301
    @type password: string
302
    @param password: the password to connect with
303
    @type config_ssl_verification: callable
304
    @param config_ssl_verification: Function to configure SSL context for
305
                                    certificate verification
306
    @type ignore_proxy: bool
307
    @param ignore_proxy: Whether to ignore proxy settings
308
    @param logger: Logging object
309

310
    """
311
    self._host = host
312
    self._port = port
313
    self._logger = logger
314

    
315
    self._base_url = "https://%s:%s" % (host, port)
316

    
317
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
318

    
319
    if username is not None:
320
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
321
      pwmgr.add_password(None, self._base_url, username, password)
322
      handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
323
    elif password:
324
      raise Error("Specified password without username")
325

    
326
    if ignore_proxy:
327
      handlers.append(urllib2.ProxyHandler({}))
328

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

    
331
    self._headers = {
332
      "Accept": HTTP_APP_JSON,
333
      "Content-type": HTTP_APP_JSON,
334
      "User-Agent": self.USER_AGENT,
335
      }
336

    
337
  @staticmethod
338
  def _EncodeQuery(query):
339
    """Encode query values for RAPI URL.
340

341
    @type query: list of two-tuples
342
    @param query: Query arguments
343
    @rtype: list
344
    @return: Query list with encoded values
345

346
    """
347
    result = []
348

    
349
    for name, value in query:
350
      if value is None:
351
        result.append((name, ""))
352

    
353
      elif isinstance(value, bool):
354
        # Boolean values must be encoded as 0 or 1
355
        result.append((name, int(value)))
356

    
357
      elif isinstance(value, (list, tuple, dict)):
358
        raise ValueError("Invalid query data type %r" % type(value).__name__)
359

    
360
      else:
361
        result.append((name, value))
362

    
363
    return result
364

    
365
  def _SendRequest(self, method, path, query, content):
366
    """Sends an HTTP request.
367

368
    This constructs a full URL, encodes and decodes HTTP bodies, and
369
    handles invalid responses in a pythonic way.
370

371
    @type method: string
372
    @param method: HTTP method to use
373
    @type path: string
374
    @param path: HTTP URL path
375
    @type query: list of two-tuples
376
    @param query: query arguments to pass to urllib.urlencode
377
    @type content: str or None
378
    @param content: HTTP body content
379

380
    @rtype: str
381
    @return: JSON-Decoded response
382

383
    @raises CertificateError: If an invalid SSL certificate is found
384
    @raises GanetiApiError: If an invalid response is returned
385

386
    """
387
    assert path.startswith("/")
388

    
389
    if content:
390
      encoded_content = self._json_encoder.encode(content)
391
    else:
392
      encoded_content = None
393

    
394
    # Build URL
395
    url = [self._base_url, path]
396
    if query:
397
      url.append("?")
398
      url.append(urllib.urlencode(self._EncodeQuery(query)))
399

    
400
    req = _RapiRequest(method, "".join(url), self._headers, encoded_content)
401

    
402
    try:
403
      resp = self._http.open(req)
404
      encoded_response_content = resp.read()
405
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
406
      raise CertificateError("SSL issue: %r" % err)
407

    
408
    if encoded_response_content:
409
      response_content = simplejson.loads(encoded_response_content)
410
    else:
411
      response_content = None
412

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

    
423
      raise GanetiApiError(msg, code=resp.code)
424

    
425
    return response_content
426

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

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

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

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

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

442
    """
443
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
444
                             None, None)
445

    
446
  def GetInfo(self):
447
    """Gets info about the cluster.
448

449
    @rtype: dict
450
    @return: information about the cluster
451

452
    """
453
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
454
                             None, None)
455

    
456
  def GetClusterTags(self):
457
    """Gets the cluster tags.
458

459
    @rtype: list of str
460
    @return: cluster tags
461

462
    """
463
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
464
                             None, None)
465

    
466
  def AddClusterTags(self, tags, dry_run=False):
467
    """Adds tags to the cluster.
468

469
    @type tags: list of str
470
    @param tags: tags to add to the cluster
471
    @type dry_run: bool
472
    @param dry_run: whether to perform a dry run
473

474
    @rtype: int
475
    @return: job id
476

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

    
482
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
483
                             query, None)
484

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

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

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

    
498
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
499
                             query, None)
500

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

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

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

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

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

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

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

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

532
    """
533
    return self._SendRequest(HTTP_GET,
534
                             ("/%s/instances/%s" %
535
                              (GANETI_RAPI_VERSION, instance)), None, None)
536

    
537
  def CreateInstance(self, dry_run=False):
538
    """Creates a new instance.
539

540
    @type dry_run: bool
541
    @param dry_run: whether to perform a dry run
542

543
    @rtype: int
544
    @return: job id
545

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

    
552
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
553
                             query, None)
554

    
555
  def DeleteInstance(self, instance, dry_run=False):
556
    """Deletes an instance.
557

558
    @type instance: str
559
    @param instance: the instance to delete
560

561
    @rtype: int
562
    @return: job id
563

564
    """
565
    query = []
566
    if dry_run:
567
      query.append(("dry-run", 1))
568

    
569
    return self._SendRequest(HTTP_DELETE,
570
                             ("/%s/instances/%s" %
571
                              (GANETI_RAPI_VERSION, instance)), query, None)
572

    
573
  def GetInstanceTags(self, instance):
574
    """Gets tags for an instance.
575

576
    @type instance: str
577
    @param instance: instance whose tags to return
578

579
    @rtype: list of str
580
    @return: tags for the instance
581

582
    """
583
    return self._SendRequest(HTTP_GET,
584
                             ("/%s/instances/%s/tags" %
585
                              (GANETI_RAPI_VERSION, instance)), None, None)
586

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

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

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

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

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

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

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

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

    
624
    return self._SendRequest(HTTP_DELETE,
625
                             ("/%s/instances/%s/tags" %
626
                              (GANETI_RAPI_VERSION, instance)), query, None)
627

    
628
  def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
629
                     dry_run=False):
630
    """Reboots an instance.
631

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

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

    
651
    return self._SendRequest(HTTP_POST,
652
                             ("/%s/instances/%s/reboot" %
653
                              (GANETI_RAPI_VERSION, instance)), query, None)
654

    
655
  def ShutdownInstance(self, instance, dry_run=False):
656
    """Shuts down an instance.
657

658
    @type instance: str
659
    @param instance: the instance to shut down
660
    @type dry_run: bool
661
    @param dry_run: whether to perform a dry run
662

663
    """
664
    query = []
665
    if dry_run:
666
      query.append(("dry-run", 1))
667

    
668
    return self._SendRequest(HTTP_PUT,
669
                             ("/%s/instances/%s/shutdown" %
670
                              (GANETI_RAPI_VERSION, instance)), query, None)
671

    
672
  def StartupInstance(self, instance, dry_run=False):
673
    """Starts up an instance.
674

675
    @type instance: str
676
    @param instance: the instance to start up
677
    @type dry_run: bool
678
    @param dry_run: whether to perform a dry run
679

680
    """
681
    query = []
682
    if dry_run:
683
      query.append(("dry-run", 1))
684

    
685
    return self._SendRequest(HTTP_PUT,
686
                             ("/%s/instances/%s/startup" %
687
                              (GANETI_RAPI_VERSION, instance)), query, None)
688

    
689
  def ReinstallInstance(self, instance, os, no_startup=False):
690
    """Reinstalls an instance.
691

692
    @type instance: str
693
    @param instance: the instance to reinstall
694
    @type os: str
695
    @param os: the os to reinstall
696
    @type no_startup: bool
697
    @param no_startup: whether to start the instance automatically
698

699
    """
700
    query = [("os", os)]
701
    if no_startup:
702
      query.append(("nostartup", 1))
703
    return self._SendRequest(HTTP_POST,
704
                             ("/%s/instances/%s/reinstall" %
705
                              (GANETI_RAPI_VERSION, instance)), query, None)
706

    
707
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
708
                           remote_node=None, iallocator=None, dry_run=False):
709
    """Replaces disks on an instance.
710

711
    @type instance: str
712
    @param instance: instance whose disks to replace
713
    @type disks: list of ints
714
    @param disks: Indexes of disks to replace
715
    @type mode: str
716
    @param mode: replacement mode to use (defaults to replace_auto)
717
    @type remote_node: str or None
718
    @param remote_node: new secondary node to use (for use with
719
        replace_new_secondary mode)
720
    @type iallocator: str or None
721
    @param iallocator: instance allocator plugin to use (for use with
722
                       replace_auto mode)
723
    @type dry_run: bool
724
    @param dry_run: whether to perform a dry run
725

726
    @rtype: int
727
    @return: job id
728

729
    """
730
    query = [
731
      ("mode", mode),
732
      ]
733

    
734
    if disks:
735
      query.append(("disks", ",".join(str(idx) for idx in disks)))
736

    
737
    if remote_node:
738
      query.append(("remote_node", remote_node))
739

    
740
    if iallocator:
741
      query.append(("iallocator", iallocator))
742

    
743
    if dry_run:
744
      query.append(("dry-run", 1))
745

    
746
    return self._SendRequest(HTTP_POST,
747
                             ("/%s/instances/%s/replace-disks" %
748
                              (GANETI_RAPI_VERSION, instance)), query, None)
749

    
750
  def GetJobs(self):
751
    """Gets all jobs for the cluster.
752

753
    @rtype: list of int
754
    @return: job ids for the cluster
755

756
    """
757
    return [int(j["id"])
758
            for j in self._SendRequest(HTTP_GET,
759
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
760
                                       None, None)]
761

    
762
  def GetJobStatus(self, job_id):
763
    """Gets the status of a job.
764

765
    @type job_id: int
766
    @param job_id: job id whose status to query
767

768
    @rtype: dict
769
    @return: job status
770

771
    """
772
    return self._SendRequest(HTTP_GET,
773
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
774
                             None, None)
775

    
776
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
777
    """Waits for job changes.
778

779
    @type job_id: int
780
    @param job_id: Job ID for which to wait
781

782
    """
783
    body = {
784
      "fields": fields,
785
      "previous_job_info": prev_job_info,
786
      "previous_log_serial": prev_log_serial,
787
      }
788

    
789
    return self._SendRequest(HTTP_GET,
790
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
791
                             None, body)
792

    
793
  def CancelJob(self, job_id, dry_run=False):
794
    """Cancels a job.
795

796
    @type job_id: int
797
    @param job_id: id of the job to delete
798
    @type dry_run: bool
799
    @param dry_run: whether to perform a dry run
800

801
    """
802
    query = []
803
    if dry_run:
804
      query.append(("dry-run", 1))
805

    
806
    return self._SendRequest(HTTP_DELETE,
807
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
808
                             query, None)
809

    
810
  def GetNodes(self, bulk=False):
811
    """Gets all nodes in the cluster.
812

813
    @type bulk: bool
814
    @param bulk: whether to return all information about all instances
815

816
    @rtype: list of dict or str
817
    @return: if bulk is true, info about nodes in the cluster,
818
        else list of nodes in the cluster
819

820
    """
821
    query = []
822
    if bulk:
823
      query.append(("bulk", 1))
824

    
825
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
826
                              query, None)
827
    if bulk:
828
      return nodes
829
    else:
830
      return [n["id"] for n in nodes]
831

    
832
  def GetNodeInfo(self, node):
833
    """Gets information about a node.
834

835
    @type node: str
836
    @param node: node whose info to return
837

838
    @rtype: dict
839
    @return: info about the node
840

841
    """
842
    return self._SendRequest(HTTP_GET,
843
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
844
                             None, None)
845

    
846
  def EvacuateNode(self, node, iallocator=None, remote_node=None,
847
                   dry_run=False):
848
    """Evacuates instances from a Ganeti node.
849

850
    @type node: str
851
    @param node: node to evacuate
852
    @type iallocator: str or None
853
    @param iallocator: instance allocator to use
854
    @type remote_node: str
855
    @param remote_node: node to evaucate to
856
    @type dry_run: bool
857
    @param dry_run: whether to perform a dry run
858

859
    @rtype: int
860
    @return: job id
861

862
    @raises GanetiApiError: if an iallocator and remote_node are both specified
863

864
    """
865
    if iallocator and remote_node:
866
      raise GanetiApiError("Only one of iallocator or remote_node can be used")
867

    
868
    query = []
869
    if iallocator:
870
      query.append(("iallocator", iallocator))
871
    if remote_node:
872
      query.append(("remote_node", remote_node))
873
    if dry_run:
874
      query.append(("dry-run", 1))
875

    
876
    return self._SendRequest(HTTP_POST,
877
                             ("/%s/nodes/%s/evacuate" %
878
                              (GANETI_RAPI_VERSION, node)), query, None)
879

    
880
  def MigrateNode(self, node, live=True, dry_run=False):
881
    """Migrates all primary instances from a node.
882

883
    @type node: str
884
    @param node: node to migrate
885
    @type live: bool
886
    @param live: whether to use live migration
887
    @type dry_run: bool
888
    @param dry_run: whether to perform a dry run
889

890
    @rtype: int
891
    @return: job id
892

893
    """
894
    query = []
895
    if live:
896
      query.append(("live", 1))
897
    if dry_run:
898
      query.append(("dry-run", 1))
899

    
900
    return self._SendRequest(HTTP_POST,
901
                             ("/%s/nodes/%s/migrate" %
902
                              (GANETI_RAPI_VERSION, node)), query, None)
903

    
904
  def GetNodeRole(self, node):
905
    """Gets the current role for a node.
906

907
    @type node: str
908
    @param node: node whose role to return
909

910
    @rtype: str
911
    @return: the current role for a node
912

913
    """
914
    return self._SendRequest(HTTP_GET,
915
                             ("/%s/nodes/%s/role" %
916
                              (GANETI_RAPI_VERSION, node)), None, None)
917

    
918
  def SetNodeRole(self, node, role, force=False):
919
    """Sets the role for a node.
920

921
    @type node: str
922
    @param node: the node whose role to set
923
    @type role: str
924
    @param role: the role to set for the node
925
    @type force: bool
926
    @param force: whether to force the role change
927

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

931
    """
932
    query = [
933
      ("force", force),
934
      ]
935

    
936
    return self._SendRequest(HTTP_PUT,
937
                             ("/%s/nodes/%s/role" %
938
                              (GANETI_RAPI_VERSION, node)), query, role)
939

    
940
  def GetNodeStorageUnits(self, node, storage_type, output_fields):
941
    """Gets the storage units for a node.
942

943
    @type node: str
944
    @param node: the node whose storage units to return
945
    @type storage_type: str
946
    @param storage_type: storage type whose units to return
947
    @type output_fields: str
948
    @param output_fields: storage type fields to return
949

950
    @rtype: int
951
    @return: job id where results can be retrieved
952

953
    """
954
    query = [
955
      ("storage_type", storage_type),
956
      ("output_fields", output_fields),
957
      ]
958

    
959
    return self._SendRequest(HTTP_GET,
960
                             ("/%s/nodes/%s/storage" %
961
                              (GANETI_RAPI_VERSION, node)), query, None)
962

    
963
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
964
    """Modifies parameters of storage units on the node.
965

966
    @type node: str
967
    @param node: node whose storage units to modify
968
    @type storage_type: str
969
    @param storage_type: storage type whose units to modify
970
    @type name: str
971
    @param name: name of the storage unit
972
    @type allocatable: bool or None
973
    @param allocatable: Whether to set the "allocatable" flag on the storage
974
                        unit (None=no modification, True=set, False=unset)
975

976
    @rtype: int
977
    @return: job id
978

979
    """
980
    query = [
981
      ("storage_type", storage_type),
982
      ("name", name),
983
      ]
984

    
985
    if allocatable is not None:
986
      query.append(("allocatable", allocatable))
987

    
988
    return self._SendRequest(HTTP_PUT,
989
                             ("/%s/nodes/%s/storage/modify" %
990
                              (GANETI_RAPI_VERSION, node)), query, None)
991

    
992
  def RepairNodeStorageUnits(self, node, storage_type, name):
993
    """Repairs a storage unit on the node.
994

995
    @type node: str
996
    @param node: node whose storage units to repair
997
    @type storage_type: str
998
    @param storage_type: storage type to repair
999
    @type name: str
1000
    @param name: name of the storage unit to repair
1001

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

1005
    """
1006
    query = [
1007
      ("storage_type", storage_type),
1008
      ("name", name),
1009
      ]
1010

    
1011
    return self._SendRequest(HTTP_PUT,
1012
                             ("/%s/nodes/%s/storage/repair" %
1013
                              (GANETI_RAPI_VERSION, node)), query, None)
1014

    
1015
  def GetNodeTags(self, node):
1016
    """Gets the tags for a node.
1017

1018
    @type node: str
1019
    @param node: node whose tags to return
1020

1021
    @rtype: list of str
1022
    @return: tags for the node
1023

1024
    """
1025
    return self._SendRequest(HTTP_GET,
1026
                             ("/%s/nodes/%s/tags" %
1027
                              (GANETI_RAPI_VERSION, node)), None, None)
1028

    
1029
  def AddNodeTags(self, node, tags, dry_run=False):
1030
    """Adds tags to a node.
1031

1032
    @type node: str
1033
    @param node: node to add tags to
1034
    @type tags: list of str
1035
    @param tags: tags to add to the node
1036
    @type dry_run: bool
1037
    @param dry_run: whether to perform a dry run
1038

1039
    @rtype: int
1040
    @return: job id
1041

1042
    """
1043
    query = [("tag", t) for t in tags]
1044
    if dry_run:
1045
      query.append(("dry-run", 1))
1046

    
1047
    return self._SendRequest(HTTP_PUT,
1048
                             ("/%s/nodes/%s/tags" %
1049
                              (GANETI_RAPI_VERSION, node)), query, tags)
1050

    
1051
  def DeleteNodeTags(self, node, tags, dry_run=False):
1052
    """Delete tags from a node.
1053

1054
    @type node: str
1055
    @param node: node to remove tags from
1056
    @type tags: list of str
1057
    @param tags: tags to remove from the node
1058
    @type dry_run: bool
1059
    @param dry_run: whether to perform a dry run
1060

1061
    @rtype: int
1062
    @return: job id
1063

1064
    """
1065
    query = [("tag", t) for t in tags]
1066
    if dry_run:
1067
      query.append(("dry-run", 1))
1068

    
1069
    return self._SendRequest(HTTP_DELETE,
1070
                             ("/%s/nodes/%s/tags" %
1071
                              (GANETI_RAPI_VERSION, node)), query, None)