RAPI client: Don't check node role in client
[ganeti-local] / lib / rapi / client.py
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: %s" % 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)