Add new spindle_count node parameter
[ganeti-local] / lib / rapi / client.py
1 #
2 #
3
4 # Copyright (C) 2010, 2011 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 @attention: To use the RAPI client, the application B{must} call
25             C{pycurl.global_init} during initialization and
26             C{pycurl.global_cleanup} before exiting the process. This is very
27             important in multi-threaded programs. See curl_global_init(3) and
28             curl_global_cleanup(3) for details. The decorator L{UsesRapiClient}
29             can be used.
30
31 """
32
33 # No Ganeti-specific modules should be imported. The RAPI client is supposed to
34 # be standalone.
35
36 import logging
37 import simplejson
38 import socket
39 import urllib
40 import threading
41 import pycurl
42 import time
43
44 try:
45   from cStringIO import StringIO
46 except ImportError:
47   from StringIO import StringIO
48
49
50 GANETI_RAPI_PORT = 5080
51 GANETI_RAPI_VERSION = 2
52
53 HTTP_DELETE = "DELETE"
54 HTTP_GET = "GET"
55 HTTP_PUT = "PUT"
56 HTTP_POST = "POST"
57 HTTP_OK = 200
58 HTTP_NOT_FOUND = 404
59 HTTP_APP_JSON = "application/json"
60
61 REPLACE_DISK_PRI = "replace_on_primary"
62 REPLACE_DISK_SECONDARY = "replace_on_secondary"
63 REPLACE_DISK_CHG = "replace_new_secondary"
64 REPLACE_DISK_AUTO = "replace_auto"
65
66 NODE_EVAC_PRI = "primary-only"
67 NODE_EVAC_SEC = "secondary-only"
68 NODE_EVAC_ALL = "all"
69
70 NODE_ROLE_DRAINED = "drained"
71 NODE_ROLE_MASTER_CANDIATE = "master-candidate"
72 NODE_ROLE_MASTER = "master"
73 NODE_ROLE_OFFLINE = "offline"
74 NODE_ROLE_REGULAR = "regular"
75
76 JOB_STATUS_QUEUED = "queued"
77 JOB_STATUS_WAITING = "waiting"
78 JOB_STATUS_CANCELING = "canceling"
79 JOB_STATUS_RUNNING = "running"
80 JOB_STATUS_CANCELED = "canceled"
81 JOB_STATUS_SUCCESS = "success"
82 JOB_STATUS_ERROR = "error"
83 JOB_STATUS_FINALIZED = frozenset([
84   JOB_STATUS_CANCELED,
85   JOB_STATUS_SUCCESS,
86   JOB_STATUS_ERROR,
87   ])
88 JOB_STATUS_ALL = frozenset([
89   JOB_STATUS_QUEUED,
90   JOB_STATUS_WAITING,
91   JOB_STATUS_CANCELING,
92   JOB_STATUS_RUNNING,
93   ]) | JOB_STATUS_FINALIZED
94
95 # Legacy name
96 JOB_STATUS_WAITLOCK = JOB_STATUS_WAITING
97
98 # Internal constants
99 _REQ_DATA_VERSION_FIELD = "__version__"
100 _QPARAM_DRY_RUN = "dry-run"
101 _QPARAM_FORCE = "force"
102
103 # Feature strings
104 INST_CREATE_REQV1 = "instance-create-reqv1"
105 INST_REINSTALL_REQV1 = "instance-reinstall-reqv1"
106 NODE_MIGRATE_REQV1 = "node-migrate-reqv1"
107 NODE_EVAC_RES1 = "node-evac-res1"
108
109 # Old feature constant names in case they're references by users of this module
110 _INST_CREATE_REQV1 = INST_CREATE_REQV1
111 _INST_REINSTALL_REQV1 = INST_REINSTALL_REQV1
112 _NODE_MIGRATE_REQV1 = NODE_MIGRATE_REQV1
113 _NODE_EVAC_RES1 = NODE_EVAC_RES1
114
115 # Older pycURL versions don't have all error constants
116 try:
117   _CURLE_SSL_CACERT = pycurl.E_SSL_CACERT
118   _CURLE_SSL_CACERT_BADFILE = pycurl.E_SSL_CACERT_BADFILE
119 except AttributeError:
120   _CURLE_SSL_CACERT = 60
121   _CURLE_SSL_CACERT_BADFILE = 77
122
123 _CURL_SSL_CERT_ERRORS = frozenset([
124   _CURLE_SSL_CACERT,
125   _CURLE_SSL_CACERT_BADFILE,
126   ])
127
128
129 class Error(Exception):
130   """Base error class for this module.
131
132   """
133   pass
134
135
136 class CertificateError(Error):
137   """Raised when a problem is found with the SSL certificate.
138
139   """
140   pass
141
142
143 class GanetiApiError(Error):
144   """Generic error raised from Ganeti API.
145
146   """
147   def __init__(self, msg, code=None):
148     Error.__init__(self, msg)
149     self.code = code
150
151
152 def _AppendIf(container, condition, value):
153   """Appends to a list if a condition evaluates to truth.
154
155   """
156   if condition:
157     container.append(value)
158
159   return condition
160
161
162 def _AppendDryRunIf(container, condition):
163   """Appends a "dry-run" parameter if a condition evaluates to truth.
164
165   """
166   return _AppendIf(container, condition, (_QPARAM_DRY_RUN, 1))
167
168
169 def _AppendForceIf(container, condition):
170   """Appends a "force" parameter if a condition evaluates to truth.
171
172   """
173   return _AppendIf(container, condition, (_QPARAM_FORCE, 1))
174
175
176 def _SetItemIf(container, condition, item, value):
177   """Sets an item if a condition evaluates to truth.
178
179   """
180   if condition:
181     container[item] = value
182
183   return condition
184
185
186 def UsesRapiClient(fn):
187   """Decorator for code using RAPI client to initialize pycURL.
188
189   """
190   def wrapper(*args, **kwargs):
191     # curl_global_init(3) and curl_global_cleanup(3) must be called with only
192     # one thread running. This check is just a safety measure -- it doesn't
193     # cover all cases.
194     assert threading.activeCount() == 1, \
195            "Found active threads when initializing pycURL"
196
197     pycurl.global_init(pycurl.GLOBAL_ALL)
198     try:
199       return fn(*args, **kwargs)
200     finally:
201       pycurl.global_cleanup()
202
203   return wrapper
204
205
206 def GenericCurlConfig(verbose=False, use_signal=False,
207                       use_curl_cabundle=False, cafile=None, capath=None,
208                       proxy=None, verify_hostname=False,
209                       connect_timeout=None, timeout=None,
210                       _pycurl_version_fn=pycurl.version_info):
211   """Curl configuration function generator.
212
213   @type verbose: bool
214   @param verbose: Whether to set cURL to verbose mode
215   @type use_signal: bool
216   @param use_signal: Whether to allow cURL to use signals
217   @type use_curl_cabundle: bool
218   @param use_curl_cabundle: Whether to use cURL's default CA bundle
219   @type cafile: string
220   @param cafile: In which file we can find the certificates
221   @type capath: string
222   @param capath: In which directory we can find the certificates
223   @type proxy: string
224   @param proxy: Proxy to use, None for default behaviour and empty string for
225                 disabling proxies (see curl_easy_setopt(3))
226   @type verify_hostname: bool
227   @param verify_hostname: Whether to verify the remote peer certificate's
228                           commonName
229   @type connect_timeout: number
230   @param connect_timeout: Timeout for establishing connection in seconds
231   @type timeout: number
232   @param timeout: Timeout for complete transfer in seconds (see
233                   curl_easy_setopt(3)).
234
235   """
236   if use_curl_cabundle and (cafile or capath):
237     raise Error("Can not use default CA bundle when CA file or path is set")
238
239   def _ConfigCurl(curl, logger):
240     """Configures a cURL object
241
242     @type curl: pycurl.Curl
243     @param curl: cURL object
244
245     """
246     logger.debug("Using cURL version %s", pycurl.version)
247
248     # pycurl.version_info returns a tuple with information about the used
249     # version of libcurl. Item 5 is the SSL library linked to it.
250     # e.g.: (3, '7.18.0', 463360, 'x86_64-pc-linux-gnu', 1581, 'GnuTLS/2.0.4',
251     # 0, '1.2.3.3', ...)
252     sslver = _pycurl_version_fn()[5]
253     if not sslver:
254       raise Error("No SSL support in cURL")
255
256     lcsslver = sslver.lower()
257     if lcsslver.startswith("openssl/"):
258       pass
259     elif lcsslver.startswith("gnutls/"):
260       if capath:
261         raise Error("cURL linked against GnuTLS has no support for a"
262                     " CA path (%s)" % (pycurl.version, ))
263     else:
264       raise NotImplementedError("cURL uses unsupported SSL version '%s'" %
265                                 sslver)
266
267     curl.setopt(pycurl.VERBOSE, verbose)
268     curl.setopt(pycurl.NOSIGNAL, not use_signal)
269
270     # Whether to verify remote peer's CN
271     if verify_hostname:
272       # curl_easy_setopt(3): "When CURLOPT_SSL_VERIFYHOST is 2, that
273       # certificate must indicate that the server is the server to which you
274       # meant to connect, or the connection fails. [...] When the value is 1,
275       # the certificate must contain a Common Name field, but it doesn't matter
276       # what name it says. [...]"
277       curl.setopt(pycurl.SSL_VERIFYHOST, 2)
278     else:
279       curl.setopt(pycurl.SSL_VERIFYHOST, 0)
280
281     if cafile or capath or use_curl_cabundle:
282       # Require certificates to be checked
283       curl.setopt(pycurl.SSL_VERIFYPEER, True)
284       if cafile:
285         curl.setopt(pycurl.CAINFO, str(cafile))
286       if capath:
287         curl.setopt(pycurl.CAPATH, str(capath))
288       # Not changing anything for using default CA bundle
289     else:
290       # Disable SSL certificate verification
291       curl.setopt(pycurl.SSL_VERIFYPEER, False)
292
293     if proxy is not None:
294       curl.setopt(pycurl.PROXY, str(proxy))
295
296     # Timeouts
297     if connect_timeout is not None:
298       curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
299     if timeout is not None:
300       curl.setopt(pycurl.TIMEOUT, timeout)
301
302   return _ConfigCurl
303
304
305 class GanetiRapiClient(object): # pylint: disable=R0904
306   """Ganeti RAPI client.
307
308   """
309   USER_AGENT = "Ganeti RAPI Client"
310   _json_encoder = simplejson.JSONEncoder(sort_keys=True)
311
312   def __init__(self, host, port=GANETI_RAPI_PORT,
313                username=None, password=None, logger=logging,
314                curl_config_fn=None, curl_factory=None):
315     """Initializes this class.
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 curl_config_fn: callable
326     @param curl_config_fn: Function to configure C{pycurl.Curl} object
327     @param logger: Logging object
328
329     """
330     self._username = username
331     self._password = password
332     self._logger = logger
333     self._curl_config_fn = curl_config_fn
334     self._curl_factory = curl_factory
335
336     try:
337       socket.inet_pton(socket.AF_INET6, host)
338       address = "[%s]:%s" % (host, port)
339     except socket.error:
340       address = "%s:%s" % (host, port)
341
342     self._base_url = "https://%s" % address
343
344     if username is not None:
345       if password is None:
346         raise Error("Password not specified")
347     elif password:
348       raise Error("Specified password without username")
349
350   def _CreateCurl(self):
351     """Creates a cURL object.
352
353     """
354     # Create pycURL object if no factory is provided
355     if self._curl_factory:
356       curl = self._curl_factory()
357     else:
358       curl = pycurl.Curl()
359
360     # Default cURL settings
361     curl.setopt(pycurl.VERBOSE, False)
362     curl.setopt(pycurl.FOLLOWLOCATION, False)
363     curl.setopt(pycurl.MAXREDIRS, 5)
364     curl.setopt(pycurl.NOSIGNAL, True)
365     curl.setopt(pycurl.USERAGENT, self.USER_AGENT)
366     curl.setopt(pycurl.SSL_VERIFYHOST, 0)
367     curl.setopt(pycurl.SSL_VERIFYPEER, False)
368     curl.setopt(pycurl.HTTPHEADER, [
369       "Accept: %s" % HTTP_APP_JSON,
370       "Content-type: %s" % HTTP_APP_JSON,
371       ])
372
373     assert ((self._username is None and self._password is None) ^
374             (self._username is not None and self._password is not None))
375
376     if self._username:
377       # Setup authentication
378       curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
379       curl.setopt(pycurl.USERPWD,
380                   str("%s:%s" % (self._username, self._password)))
381
382     # Call external configuration function
383     if self._curl_config_fn:
384       self._curl_config_fn(curl, self._logger)
385
386     return curl
387
388   @staticmethod
389   def _EncodeQuery(query):
390     """Encode query values for RAPI URL.
391
392     @type query: list of two-tuples
393     @param query: Query arguments
394     @rtype: list
395     @return: Query list with encoded values
396
397     """
398     result = []
399
400     for name, value in query:
401       if value is None:
402         result.append((name, ""))
403
404       elif isinstance(value, bool):
405         # Boolean values must be encoded as 0 or 1
406         result.append((name, int(value)))
407
408       elif isinstance(value, (list, tuple, dict)):
409         raise ValueError("Invalid query data type %r" % type(value).__name__)
410
411       else:
412         result.append((name, value))
413
414     return result
415
416   def _SendRequest(self, method, path, query, content):
417     """Sends an HTTP request.
418
419     This constructs a full URL, encodes and decodes HTTP bodies, and
420     handles invalid responses in a pythonic way.
421
422     @type method: string
423     @param method: HTTP method to use
424     @type path: string
425     @param path: HTTP URL path
426     @type query: list of two-tuples
427     @param query: query arguments to pass to urllib.urlencode
428     @type content: str or None
429     @param content: HTTP body content
430
431     @rtype: str
432     @return: JSON-Decoded response
433
434     @raises CertificateError: If an invalid SSL certificate is found
435     @raises GanetiApiError: If an invalid response is returned
436
437     """
438     assert path.startswith("/")
439
440     curl = self._CreateCurl()
441
442     if content is not None:
443       encoded_content = self._json_encoder.encode(content)
444     else:
445       encoded_content = ""
446
447     # Build URL
448     urlparts = [self._base_url, path]
449     if query:
450       urlparts.append("?")
451       urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
452
453     url = "".join(urlparts)
454
455     self._logger.debug("Sending request %s %s (content=%r)",
456                        method, url, encoded_content)
457
458     # Buffer for response
459     encoded_resp_body = StringIO()
460
461     # Configure cURL
462     curl.setopt(pycurl.CUSTOMREQUEST, str(method))
463     curl.setopt(pycurl.URL, str(url))
464     curl.setopt(pycurl.POSTFIELDS, str(encoded_content))
465     curl.setopt(pycurl.WRITEFUNCTION, encoded_resp_body.write)
466
467     try:
468       # Send request and wait for response
469       try:
470         curl.perform()
471       except pycurl.error, err:
472         if err.args[0] in _CURL_SSL_CERT_ERRORS:
473           raise CertificateError("SSL certificate error %s" % err)
474
475         raise GanetiApiError(str(err))
476     finally:
477       # Reset settings to not keep references to large objects in memory
478       # between requests
479       curl.setopt(pycurl.POSTFIELDS, "")
480       curl.setopt(pycurl.WRITEFUNCTION, lambda _: None)
481
482     # Get HTTP response code
483     http_code = curl.getinfo(pycurl.RESPONSE_CODE)
484
485     # Was anything written to the response buffer?
486     if encoded_resp_body.tell():
487       response_content = simplejson.loads(encoded_resp_body.getvalue())
488     else:
489       response_content = None
490
491     if http_code != HTTP_OK:
492       if isinstance(response_content, dict):
493         msg = ("%s %s: %s" %
494                (response_content["code"],
495                 response_content["message"],
496                 response_content["explain"]))
497       else:
498         msg = str(response_content)
499
500       raise GanetiApiError(msg, code=http_code)
501
502     return response_content
503
504   def GetVersion(self):
505     """Gets the Remote API version running on the cluster.
506
507     @rtype: int
508     @return: Ganeti Remote API version
509
510     """
511     return self._SendRequest(HTTP_GET, "/version", None, None)
512
513   def GetFeatures(self):
514     """Gets the list of optional features supported by RAPI server.
515
516     @rtype: list
517     @return: List of optional features
518
519     """
520     try:
521       return self._SendRequest(HTTP_GET, "/%s/features" % GANETI_RAPI_VERSION,
522                                None, None)
523     except GanetiApiError, err:
524       # Older RAPI servers don't support this resource
525       if err.code == HTTP_NOT_FOUND:
526         return []
527
528       raise
529
530   def GetOperatingSystems(self):
531     """Gets the Operating Systems running in the Ganeti cluster.
532
533     @rtype: list of str
534     @return: operating systems
535
536     """
537     return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
538                              None, None)
539
540   def GetInfo(self):
541     """Gets info about the cluster.
542
543     @rtype: dict
544     @return: information about the cluster
545
546     """
547     return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
548                              None, None)
549
550   def RedistributeConfig(self):
551     """Tells the cluster to redistribute its configuration files.
552
553     @rtype: string
554     @return: job id
555
556     """
557     return self._SendRequest(HTTP_PUT,
558                              "/%s/redistribute-config" % GANETI_RAPI_VERSION,
559                              None, None)
560
561   def ModifyCluster(self, **kwargs):
562     """Modifies cluster parameters.
563
564     More details for parameters can be found in the RAPI documentation.
565
566     @rtype: string
567     @return: job id
568
569     """
570     body = kwargs
571
572     return self._SendRequest(HTTP_PUT,
573                              "/%s/modify" % GANETI_RAPI_VERSION, None, body)
574
575   def GetClusterTags(self):
576     """Gets the cluster tags.
577
578     @rtype: list of str
579     @return: cluster tags
580
581     """
582     return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
583                              None, None)
584
585   def AddClusterTags(self, tags, dry_run=False):
586     """Adds tags to the cluster.
587
588     @type tags: list of str
589     @param tags: tags to add to the cluster
590     @type dry_run: bool
591     @param dry_run: whether to perform a dry run
592
593     @rtype: string
594     @return: job id
595
596     """
597     query = [("tag", t) for t in tags]
598     _AppendDryRunIf(query, dry_run)
599
600     return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
601                              query, None)
602
603   def DeleteClusterTags(self, tags, dry_run=False):
604     """Deletes tags from the cluster.
605
606     @type tags: list of str
607     @param tags: tags to delete
608     @type dry_run: bool
609     @param dry_run: whether to perform a dry run
610     @rtype: string
611     @return: job id
612
613     """
614     query = [("tag", t) for t in tags]
615     _AppendDryRunIf(query, dry_run)
616
617     return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
618                              query, None)
619
620   def GetInstances(self, bulk=False):
621     """Gets information about instances on the cluster.
622
623     @type bulk: bool
624     @param bulk: whether to return all information about all instances
625
626     @rtype: list of dict or list of str
627     @return: if bulk is True, info about the instances, else a list of instances
628
629     """
630     query = []
631     _AppendIf(query, bulk, ("bulk", 1))
632
633     instances = self._SendRequest(HTTP_GET,
634                                   "/%s/instances" % GANETI_RAPI_VERSION,
635                                   query, None)
636     if bulk:
637       return instances
638     else:
639       return [i["id"] for i in instances]
640
641   def GetInstance(self, instance):
642     """Gets information about an instance.
643
644     @type instance: str
645     @param instance: instance whose info to return
646
647     @rtype: dict
648     @return: info about the instance
649
650     """
651     return self._SendRequest(HTTP_GET,
652                              ("/%s/instances/%s" %
653                               (GANETI_RAPI_VERSION, instance)), None, None)
654
655   def GetInstanceInfo(self, instance, static=None):
656     """Gets information about an instance.
657
658     @type instance: string
659     @param instance: Instance name
660     @rtype: string
661     @return: Job ID
662
663     """
664     if static is not None:
665       query = [("static", static)]
666     else:
667       query = None
668
669     return self._SendRequest(HTTP_GET,
670                              ("/%s/instances/%s/info" %
671                               (GANETI_RAPI_VERSION, instance)), query, None)
672
673   def CreateInstance(self, mode, name, disk_template, disks, nics,
674                      **kwargs):
675     """Creates a new instance.
676
677     More details for parameters can be found in the RAPI documentation.
678
679     @type mode: string
680     @param mode: Instance creation mode
681     @type name: string
682     @param name: Hostname of the instance to create
683     @type disk_template: string
684     @param disk_template: Disk template for instance (e.g. plain, diskless,
685                           file, or drbd)
686     @type disks: list of dicts
687     @param disks: List of disk definitions
688     @type nics: list of dicts
689     @param nics: List of NIC definitions
690     @type dry_run: bool
691     @keyword dry_run: whether to perform a dry run
692
693     @rtype: string
694     @return: job id
695
696     """
697     query = []
698
699     _AppendDryRunIf(query, kwargs.get("dry_run"))
700
701     if _INST_CREATE_REQV1 in self.GetFeatures():
702       # All required fields for request data version 1
703       body = {
704         _REQ_DATA_VERSION_FIELD: 1,
705         "mode": mode,
706         "name": name,
707         "disk_template": disk_template,
708         "disks": disks,
709         "nics": nics,
710         }
711
712       conflicts = set(kwargs.iterkeys()) & set(body.iterkeys())
713       if conflicts:
714         raise GanetiApiError("Required fields can not be specified as"
715                              " keywords: %s" % ", ".join(conflicts))
716
717       body.update((key, value) for key, value in kwargs.iteritems()
718                   if key != "dry_run")
719     else:
720       raise GanetiApiError("Server does not support new-style (version 1)"
721                            " instance creation requests")
722
723     return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
724                              query, body)
725
726   def DeleteInstance(self, instance, dry_run=False):
727     """Deletes an instance.
728
729     @type instance: str
730     @param instance: the instance to delete
731
732     @rtype: string
733     @return: job id
734
735     """
736     query = []
737     _AppendDryRunIf(query, dry_run)
738
739     return self._SendRequest(HTTP_DELETE,
740                              ("/%s/instances/%s" %
741                               (GANETI_RAPI_VERSION, instance)), query, None)
742
743   def ModifyInstance(self, instance, **kwargs):
744     """Modifies an instance.
745
746     More details for parameters can be found in the RAPI documentation.
747
748     @type instance: string
749     @param instance: Instance name
750     @rtype: string
751     @return: job id
752
753     """
754     body = kwargs
755
756     return self._SendRequest(HTTP_PUT,
757                              ("/%s/instances/%s/modify" %
758                               (GANETI_RAPI_VERSION, instance)), None, body)
759
760   def ActivateInstanceDisks(self, instance, ignore_size=None):
761     """Activates an instance's disks.
762
763     @type instance: string
764     @param instance: Instance name
765     @type ignore_size: bool
766     @param ignore_size: Whether to ignore recorded size
767     @rtype: string
768     @return: job id
769
770     """
771     query = []
772     _AppendIf(query, ignore_size, ("ignore_size", 1))
773
774     return self._SendRequest(HTTP_PUT,
775                              ("/%s/instances/%s/activate-disks" %
776                               (GANETI_RAPI_VERSION, instance)), query, None)
777
778   def DeactivateInstanceDisks(self, instance):
779     """Deactivates an instance's disks.
780
781     @type instance: string
782     @param instance: Instance name
783     @rtype: string
784     @return: job id
785
786     """
787     return self._SendRequest(HTTP_PUT,
788                              ("/%s/instances/%s/deactivate-disks" %
789                               (GANETI_RAPI_VERSION, instance)), None, None)
790
791   def RecreateInstanceDisks(self, instance, disks=None, nodes=None):
792     """Recreate an instance's disks.
793
794     @type instance: string
795     @param instance: Instance name
796     @type disks: list of int
797     @param disks: List of disk indexes
798     @type nodes: list of string
799     @param nodes: New instance nodes, if relocation is desired
800     @rtype: string
801     @return: job id
802
803     """
804     body = {}
805     _SetItemIf(body, disks is not None, "disks", disks)
806     _SetItemIf(body, nodes is not None, "nodes", nodes)
807
808     return self._SendRequest(HTTP_POST,
809                              ("/%s/instances/%s/recreate-disks" %
810                               (GANETI_RAPI_VERSION, instance)), None, body)
811
812   def GrowInstanceDisk(self, instance, disk, amount, wait_for_sync=None):
813     """Grows a disk of an instance.
814
815     More details for parameters can be found in the RAPI documentation.
816
817     @type instance: string
818     @param instance: Instance name
819     @type disk: integer
820     @param disk: Disk index
821     @type amount: integer
822     @param amount: Grow disk by this amount (MiB)
823     @type wait_for_sync: bool
824     @param wait_for_sync: Wait for disk to synchronize
825     @rtype: string
826     @return: job id
827
828     """
829     body = {
830       "amount": amount,
831       }
832
833     _SetItemIf(body, wait_for_sync is not None, "wait_for_sync", wait_for_sync)
834
835     return self._SendRequest(HTTP_POST,
836                              ("/%s/instances/%s/disk/%s/grow" %
837                               (GANETI_RAPI_VERSION, instance, disk)),
838                              None, body)
839
840   def GetInstanceTags(self, instance):
841     """Gets tags for an instance.
842
843     @type instance: str
844     @param instance: instance whose tags to return
845
846     @rtype: list of str
847     @return: tags for the instance
848
849     """
850     return self._SendRequest(HTTP_GET,
851                              ("/%s/instances/%s/tags" %
852                               (GANETI_RAPI_VERSION, instance)), None, None)
853
854   def AddInstanceTags(self, instance, tags, dry_run=False):
855     """Adds tags to an instance.
856
857     @type instance: str
858     @param instance: instance to add tags to
859     @type tags: list of str
860     @param tags: tags to add to the instance
861     @type dry_run: bool
862     @param dry_run: whether to perform a dry run
863
864     @rtype: string
865     @return: job id
866
867     """
868     query = [("tag", t) for t in tags]
869     _AppendDryRunIf(query, dry_run)
870
871     return self._SendRequest(HTTP_PUT,
872                              ("/%s/instances/%s/tags" %
873                               (GANETI_RAPI_VERSION, instance)), query, None)
874
875   def DeleteInstanceTags(self, instance, tags, dry_run=False):
876     """Deletes tags from an instance.
877
878     @type instance: str
879     @param instance: instance to delete tags from
880     @type tags: list of str
881     @param tags: tags to delete
882     @type dry_run: bool
883     @param dry_run: whether to perform a dry run
884     @rtype: string
885     @return: job id
886
887     """
888     query = [("tag", t) for t in tags]
889     _AppendDryRunIf(query, dry_run)
890
891     return self._SendRequest(HTTP_DELETE,
892                              ("/%s/instances/%s/tags" %
893                               (GANETI_RAPI_VERSION, instance)), query, None)
894
895   def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
896                      dry_run=False):
897     """Reboots an instance.
898
899     @type instance: str
900     @param instance: instance to rebot
901     @type reboot_type: str
902     @param reboot_type: one of: hard, soft, full
903     @type ignore_secondaries: bool
904     @param ignore_secondaries: if True, ignores errors for the secondary node
905         while re-assembling disks (in hard-reboot mode only)
906     @type dry_run: bool
907     @param dry_run: whether to perform a dry run
908     @rtype: string
909     @return: job id
910
911     """
912     query = []
913     _AppendDryRunIf(query, dry_run)
914     _AppendIf(query, reboot_type, ("type", reboot_type))
915     _AppendIf(query, ignore_secondaries is not None,
916               ("ignore_secondaries", ignore_secondaries))
917
918     return self._SendRequest(HTTP_POST,
919                              ("/%s/instances/%s/reboot" %
920                               (GANETI_RAPI_VERSION, instance)), query, None)
921
922   def ShutdownInstance(self, instance, dry_run=False, no_remember=False):
923     """Shuts down an instance.
924
925     @type instance: str
926     @param instance: the instance to shut down
927     @type dry_run: bool
928     @param dry_run: whether to perform a dry run
929     @type no_remember: bool
930     @param no_remember: if true, will not record the state change
931     @rtype: string
932     @return: job id
933
934     """
935     query = []
936     _AppendDryRunIf(query, dry_run)
937     _AppendIf(query, no_remember, ("no-remember", 1))
938
939     return self._SendRequest(HTTP_PUT,
940                              ("/%s/instances/%s/shutdown" %
941                               (GANETI_RAPI_VERSION, instance)), query, None)
942
943   def StartupInstance(self, instance, dry_run=False, no_remember=False):
944     """Starts up an instance.
945
946     @type instance: str
947     @param instance: the instance to start up
948     @type dry_run: bool
949     @param dry_run: whether to perform a dry run
950     @type no_remember: bool
951     @param no_remember: if true, will not record the state change
952     @rtype: string
953     @return: job id
954
955     """
956     query = []
957     _AppendDryRunIf(query, dry_run)
958     _AppendIf(query, no_remember, ("no-remember", 1))
959
960     return self._SendRequest(HTTP_PUT,
961                              ("/%s/instances/%s/startup" %
962                               (GANETI_RAPI_VERSION, instance)), query, None)
963
964   def ReinstallInstance(self, instance, os=None, no_startup=False,
965                         osparams=None):
966     """Reinstalls an instance.
967
968     @type instance: str
969     @param instance: The instance to reinstall
970     @type os: str or None
971     @param os: The operating system to reinstall. If None, the instance's
972         current operating system will be installed again
973     @type no_startup: bool
974     @param no_startup: Whether to start the instance automatically
975     @rtype: string
976     @return: job id
977
978     """
979     if _INST_REINSTALL_REQV1 in self.GetFeatures():
980       body = {
981         "start": not no_startup,
982         }
983       _SetItemIf(body, os is not None, "os", os)
984       _SetItemIf(body, osparams is not None, "osparams", osparams)
985       return self._SendRequest(HTTP_POST,
986                                ("/%s/instances/%s/reinstall" %
987                                 (GANETI_RAPI_VERSION, instance)), None, body)
988
989     # Use old request format
990     if osparams:
991       raise GanetiApiError("Server does not support specifying OS parameters"
992                            " for instance reinstallation")
993
994     query = []
995     _AppendIf(query, os, ("os", os))
996     _AppendIf(query, no_startup, ("nostartup", 1))
997
998     return self._SendRequest(HTTP_POST,
999                              ("/%s/instances/%s/reinstall" %
1000                               (GANETI_RAPI_VERSION, instance)), query, None)
1001
1002   def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
1003                            remote_node=None, iallocator=None):
1004     """Replaces disks on an instance.
1005
1006     @type instance: str
1007     @param instance: instance whose disks to replace
1008     @type disks: list of ints
1009     @param disks: Indexes of disks to replace
1010     @type mode: str
1011     @param mode: replacement mode to use (defaults to replace_auto)
1012     @type remote_node: str or None
1013     @param remote_node: new secondary node to use (for use with
1014         replace_new_secondary mode)
1015     @type iallocator: str or None
1016     @param iallocator: instance allocator plugin to use (for use with
1017                        replace_auto mode)
1018
1019     @rtype: string
1020     @return: job id
1021
1022     """
1023     query = [
1024       ("mode", mode),
1025       ]
1026
1027     # TODO: Convert to body parameters
1028
1029     if disks is not None:
1030       _AppendIf(query, True,
1031                 ("disks", ",".join(str(idx) for idx in disks)))
1032
1033     _AppendIf(query, remote_node is not None, ("remote_node", remote_node))
1034     _AppendIf(query, iallocator is not None, ("iallocator", iallocator))
1035
1036     return self._SendRequest(HTTP_POST,
1037                              ("/%s/instances/%s/replace-disks" %
1038                               (GANETI_RAPI_VERSION, instance)), query, None)
1039
1040   def PrepareExport(self, instance, mode):
1041     """Prepares an instance for an export.
1042
1043     @type instance: string
1044     @param instance: Instance name
1045     @type mode: string
1046     @param mode: Export mode
1047     @rtype: string
1048     @return: Job ID
1049
1050     """
1051     query = [("mode", mode)]
1052     return self._SendRequest(HTTP_PUT,
1053                              ("/%s/instances/%s/prepare-export" %
1054                               (GANETI_RAPI_VERSION, instance)), query, None)
1055
1056   def ExportInstance(self, instance, mode, destination, shutdown=None,
1057                      remove_instance=None,
1058                      x509_key_name=None, destination_x509_ca=None):
1059     """Exports an instance.
1060
1061     @type instance: string
1062     @param instance: Instance name
1063     @type mode: string
1064     @param mode: Export mode
1065     @rtype: string
1066     @return: Job ID
1067
1068     """
1069     body = {
1070       "destination": destination,
1071       "mode": mode,
1072       }
1073
1074     _SetItemIf(body, shutdown is not None, "shutdown", shutdown)
1075     _SetItemIf(body, remove_instance is not None,
1076                "remove_instance", remove_instance)
1077     _SetItemIf(body, x509_key_name is not None, "x509_key_name", x509_key_name)
1078     _SetItemIf(body, destination_x509_ca is not None,
1079                "destination_x509_ca", destination_x509_ca)
1080
1081     return self._SendRequest(HTTP_PUT,
1082                              ("/%s/instances/%s/export" %
1083                               (GANETI_RAPI_VERSION, instance)), None, body)
1084
1085   def MigrateInstance(self, instance, mode=None, cleanup=None):
1086     """Migrates an instance.
1087
1088     @type instance: string
1089     @param instance: Instance name
1090     @type mode: string
1091     @param mode: Migration mode
1092     @type cleanup: bool
1093     @param cleanup: Whether to clean up a previously failed migration
1094     @rtype: string
1095     @return: job id
1096
1097     """
1098     body = {}
1099     _SetItemIf(body, mode is not None, "mode", mode)
1100     _SetItemIf(body, cleanup is not None, "cleanup", cleanup)
1101
1102     return self._SendRequest(HTTP_PUT,
1103                              ("/%s/instances/%s/migrate" %
1104                               (GANETI_RAPI_VERSION, instance)), None, body)
1105
1106   def FailoverInstance(self, instance, iallocator=None,
1107                        ignore_consistency=None, target_node=None):
1108     """Does a failover of an instance.
1109
1110     @type instance: string
1111     @param instance: Instance name
1112     @type iallocator: string
1113     @param iallocator: Iallocator for deciding the target node for
1114       shared-storage instances
1115     @type ignore_consistency: bool
1116     @param ignore_consistency: Whether to ignore disk consistency
1117     @type target_node: string
1118     @param target_node: Target node for shared-storage instances
1119     @rtype: string
1120     @return: job id
1121
1122     """
1123     body = {}
1124     _SetItemIf(body, iallocator is not None, "iallocator", iallocator)
1125     _SetItemIf(body, ignore_consistency is not None,
1126                "ignore_consistency", ignore_consistency)
1127     _SetItemIf(body, target_node is not None, "target_node", target_node)
1128
1129     return self._SendRequest(HTTP_PUT,
1130                              ("/%s/instances/%s/failover" %
1131                               (GANETI_RAPI_VERSION, instance)), None, body)
1132
1133   def RenameInstance(self, instance, new_name, ip_check=None, name_check=None):
1134     """Changes the name of an instance.
1135
1136     @type instance: string
1137     @param instance: Instance name
1138     @type new_name: string
1139     @param new_name: New instance name
1140     @type ip_check: bool
1141     @param ip_check: Whether to ensure instance's IP address is inactive
1142     @type name_check: bool
1143     @param name_check: Whether to ensure instance's name is resolvable
1144     @rtype: string
1145     @return: job id
1146
1147     """
1148     body = {
1149       "new_name": new_name,
1150       }
1151
1152     _SetItemIf(body, ip_check is not None, "ip_check", ip_check)
1153     _SetItemIf(body, name_check is not None, "name_check", name_check)
1154
1155     return self._SendRequest(HTTP_PUT,
1156                              ("/%s/instances/%s/rename" %
1157                               (GANETI_RAPI_VERSION, instance)), None, body)
1158
1159   def GetInstanceConsole(self, instance):
1160     """Request information for connecting to instance's console.
1161
1162     @type instance: string
1163     @param instance: Instance name
1164     @rtype: dict
1165     @return: dictionary containing information about instance's console
1166
1167     """
1168     return self._SendRequest(HTTP_GET,
1169                              ("/%s/instances/%s/console" %
1170                               (GANETI_RAPI_VERSION, instance)), None, None)
1171
1172   def GetJobs(self):
1173     """Gets all jobs for the cluster.
1174
1175     @rtype: list of int
1176     @return: job ids for the cluster
1177
1178     """
1179     return [int(j["id"])
1180             for j in self._SendRequest(HTTP_GET,
1181                                        "/%s/jobs" % GANETI_RAPI_VERSION,
1182                                        None, None)]
1183
1184   def GetJobStatus(self, job_id):
1185     """Gets the status of a job.
1186
1187     @type job_id: string
1188     @param job_id: job id whose status to query
1189
1190     @rtype: dict
1191     @return: job status
1192
1193     """
1194     return self._SendRequest(HTTP_GET,
1195                              "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
1196                              None, None)
1197
1198   def WaitForJobCompletion(self, job_id, period=5, retries=-1):
1199     """Polls cluster for job status until completion.
1200
1201     Completion is defined as any of the following states listed in
1202     L{JOB_STATUS_FINALIZED}.
1203
1204     @type job_id: string
1205     @param job_id: job id to watch
1206     @type period: int
1207     @param period: how often to poll for status (optional, default 5s)
1208     @type retries: int
1209     @param retries: how many time to poll before giving up
1210                     (optional, default -1 means unlimited)
1211
1212     @rtype: bool
1213     @return: C{True} if job succeeded or C{False} if failed/status timeout
1214     @deprecated: It is recommended to use L{WaitForJobChange} wherever
1215       possible; L{WaitForJobChange} returns immediately after a job changed and
1216       does not use polling
1217
1218     """
1219     while retries != 0:
1220       job_result = self.GetJobStatus(job_id)
1221
1222       if job_result and job_result["status"] == JOB_STATUS_SUCCESS:
1223         return True
1224       elif not job_result or job_result["status"] in JOB_STATUS_FINALIZED:
1225         return False
1226
1227       if period:
1228         time.sleep(period)
1229
1230       if retries > 0:
1231         retries -= 1
1232
1233     return False
1234
1235   def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
1236     """Waits for job changes.
1237
1238     @type job_id: string
1239     @param job_id: Job ID for which to wait
1240     @return: C{None} if no changes have been detected and a dict with two keys,
1241       C{job_info} and C{log_entries} otherwise.
1242     @rtype: dict
1243
1244     """
1245     body = {
1246       "fields": fields,
1247       "previous_job_info": prev_job_info,
1248       "previous_log_serial": prev_log_serial,
1249       }
1250
1251     return self._SendRequest(HTTP_GET,
1252                              "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
1253                              None, body)
1254
1255   def CancelJob(self, job_id, dry_run=False):
1256     """Cancels a job.
1257
1258     @type job_id: string
1259     @param job_id: id of the job to delete
1260     @type dry_run: bool
1261     @param dry_run: whether to perform a dry run
1262     @rtype: tuple
1263     @return: tuple containing the result, and a message (bool, string)
1264
1265     """
1266     query = []
1267     _AppendDryRunIf(query, dry_run)
1268
1269     return self._SendRequest(HTTP_DELETE,
1270                              "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
1271                              query, None)
1272
1273   def GetNodes(self, bulk=False):
1274     """Gets all nodes in the cluster.
1275
1276     @type bulk: bool
1277     @param bulk: whether to return all information about all instances
1278
1279     @rtype: list of dict or str
1280     @return: if bulk is true, info about nodes in the cluster,
1281         else list of nodes in the cluster
1282
1283     """
1284     query = []
1285     _AppendIf(query, bulk, ("bulk", 1))
1286
1287     nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
1288                               query, None)
1289     if bulk:
1290       return nodes
1291     else:
1292       return [n["id"] for n in nodes]
1293
1294   def GetNode(self, node):
1295     """Gets information about a node.
1296
1297     @type node: str
1298     @param node: node whose info to return
1299
1300     @rtype: dict
1301     @return: info about the node
1302
1303     """
1304     return self._SendRequest(HTTP_GET,
1305                              "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
1306                              None, None)
1307
1308   def EvacuateNode(self, node, iallocator=None, remote_node=None,
1309                    dry_run=False, early_release=None,
1310                    mode=None, accept_old=False):
1311     """Evacuates instances from a Ganeti node.
1312
1313     @type node: str
1314     @param node: node to evacuate
1315     @type iallocator: str or None
1316     @param iallocator: instance allocator to use
1317     @type remote_node: str
1318     @param remote_node: node to evaucate to
1319     @type dry_run: bool
1320     @param dry_run: whether to perform a dry run
1321     @type early_release: bool
1322     @param early_release: whether to enable parallelization
1323     @type mode: string
1324     @param mode: Node evacuation mode
1325     @type accept_old: bool
1326     @param accept_old: Whether caller is ready to accept old-style (pre-2.5)
1327         results
1328
1329     @rtype: string, or a list for pre-2.5 results
1330     @return: Job ID or, if C{accept_old} is set and server is pre-2.5,
1331       list of (job ID, instance name, new secondary node); if dry_run was
1332       specified, then the actual move jobs were not submitted and the job IDs
1333       will be C{None}
1334
1335     @raises GanetiApiError: if an iallocator and remote_node are both
1336         specified
1337
1338     """
1339     if iallocator and remote_node:
1340       raise GanetiApiError("Only one of iallocator or remote_node can be used")
1341
1342     query = []
1343     _AppendDryRunIf(query, dry_run)
1344
1345     if _NODE_EVAC_RES1 in self.GetFeatures():
1346       # Server supports body parameters
1347       body = {}
1348
1349       _SetItemIf(body, iallocator is not None, "iallocator", iallocator)
1350       _SetItemIf(body, remote_node is not None, "remote_node", remote_node)
1351       _SetItemIf(body, early_release is not None,
1352                  "early_release", early_release)
1353       _SetItemIf(body, mode is not None, "mode", mode)
1354     else:
1355       # Pre-2.5 request format
1356       body = None
1357
1358       if not accept_old:
1359         raise GanetiApiError("Server is version 2.4 or earlier and caller does"
1360                              " not accept old-style results (parameter"
1361                              " accept_old)")
1362
1363       # Pre-2.5 servers can only evacuate secondaries
1364       if mode is not None and mode != NODE_EVAC_SEC:
1365         raise GanetiApiError("Server can only evacuate secondary instances")
1366
1367       _AppendIf(query, iallocator, ("iallocator", iallocator))
1368       _AppendIf(query, remote_node, ("remote_node", remote_node))
1369       _AppendIf(query, early_release, ("early_release", 1))
1370
1371     return self._SendRequest(HTTP_POST,
1372                              ("/%s/nodes/%s/evacuate" %
1373                               (GANETI_RAPI_VERSION, node)), query, body)
1374
1375   def MigrateNode(self, node, mode=None, dry_run=False, iallocator=None,
1376                   target_node=None):
1377     """Migrates all primary instances from a node.
1378
1379     @type node: str
1380     @param node: node to migrate
1381     @type mode: string
1382     @param mode: if passed, it will overwrite the live migration type,
1383         otherwise the hypervisor default will be used
1384     @type dry_run: bool
1385     @param dry_run: whether to perform a dry run
1386     @type iallocator: string
1387     @param iallocator: instance allocator to use
1388     @type target_node: string
1389     @param target_node: Target node for shared-storage instances
1390
1391     @rtype: string
1392     @return: job id
1393
1394     """
1395     query = []
1396     _AppendDryRunIf(query, dry_run)
1397
1398     if _NODE_MIGRATE_REQV1 in self.GetFeatures():
1399       body = {}
1400
1401       _SetItemIf(body, mode is not None, "mode", mode)
1402       _SetItemIf(body, iallocator is not None, "iallocator", iallocator)
1403       _SetItemIf(body, target_node is not None, "target_node", target_node)
1404
1405       assert len(query) <= 1
1406
1407       return self._SendRequest(HTTP_POST,
1408                                ("/%s/nodes/%s/migrate" %
1409                                 (GANETI_RAPI_VERSION, node)), query, body)
1410     else:
1411       # Use old request format
1412       if target_node is not None:
1413         raise GanetiApiError("Server does not support specifying target node"
1414                              " for node migration")
1415
1416       _AppendIf(query, mode is not None, ("mode", mode))
1417
1418       return self._SendRequest(HTTP_POST,
1419                                ("/%s/nodes/%s/migrate" %
1420                                 (GANETI_RAPI_VERSION, node)), query, None)
1421
1422   def GetNodeRole(self, node):
1423     """Gets the current role for a node.
1424
1425     @type node: str
1426     @param node: node whose role to return
1427
1428     @rtype: str
1429     @return: the current role for a node
1430
1431     """
1432     return self._SendRequest(HTTP_GET,
1433                              ("/%s/nodes/%s/role" %
1434                               (GANETI_RAPI_VERSION, node)), None, None)
1435
1436   def SetNodeRole(self, node, role, force=False, auto_promote=None):
1437     """Sets the role for a node.
1438
1439     @type node: str
1440     @param node: the node whose role to set
1441     @type role: str
1442     @param role: the role to set for the node
1443     @type force: bool
1444     @param force: whether to force the role change
1445     @type auto_promote: bool
1446     @param auto_promote: Whether node(s) should be promoted to master candidate
1447                          if necessary
1448
1449     @rtype: string
1450     @return: job id
1451
1452     """
1453     query = []
1454     _AppendForceIf(query, force)
1455     _AppendIf(query, auto_promote is not None, ("auto-promote", auto_promote))
1456
1457     return self._SendRequest(HTTP_PUT,
1458                              ("/%s/nodes/%s/role" %
1459                               (GANETI_RAPI_VERSION, node)), query, role)
1460
1461   def PowercycleNode(self, node, force=False):
1462     """Powercycles a node.
1463
1464     @type node: string
1465     @param node: Node name
1466     @type force: bool
1467     @param force: Whether to force the operation
1468     @rtype: string
1469     @return: job id
1470
1471     """
1472     query = []
1473     _AppendForceIf(query, force)
1474
1475     return self._SendRequest(HTTP_POST,
1476                              ("/%s/nodes/%s/powercycle" %
1477                               (GANETI_RAPI_VERSION, node)), query, None)
1478
1479   def ModifyNode(self, node, **kwargs):
1480     """Modifies a node.
1481
1482     More details for parameters can be found in the RAPI documentation.
1483
1484     @type node: string
1485     @param node: Node name
1486     @rtype: string
1487     @return: job id
1488
1489     """
1490     return self._SendRequest(HTTP_POST,
1491                              ("/%s/nodes/%s/modify" %
1492                               (GANETI_RAPI_VERSION, node)), None, kwargs)
1493
1494   def GetNodeStorageUnits(self, node, storage_type, output_fields):
1495     """Gets the storage units for a node.
1496
1497     @type node: str
1498     @param node: the node whose storage units to return
1499     @type storage_type: str
1500     @param storage_type: storage type whose units to return
1501     @type output_fields: str
1502     @param output_fields: storage type fields to return
1503
1504     @rtype: string
1505     @return: job id where results can be retrieved
1506
1507     """
1508     query = [
1509       ("storage_type", storage_type),
1510       ("output_fields", output_fields),
1511       ]
1512
1513     return self._SendRequest(HTTP_GET,
1514                              ("/%s/nodes/%s/storage" %
1515                               (GANETI_RAPI_VERSION, node)), query, None)
1516
1517   def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
1518     """Modifies parameters of storage units on the node.
1519
1520     @type node: str
1521     @param node: node whose storage units to modify
1522     @type storage_type: str
1523     @param storage_type: storage type whose units to modify
1524     @type name: str
1525     @param name: name of the storage unit
1526     @type allocatable: bool or None
1527     @param allocatable: Whether to set the "allocatable" flag on the storage
1528                         unit (None=no modification, True=set, False=unset)
1529
1530     @rtype: string
1531     @return: job id
1532
1533     """
1534     query = [
1535       ("storage_type", storage_type),
1536       ("name", name),
1537       ]
1538
1539     _AppendIf(query, allocatable is not None, ("allocatable", allocatable))
1540
1541     return self._SendRequest(HTTP_PUT,
1542                              ("/%s/nodes/%s/storage/modify" %
1543                               (GANETI_RAPI_VERSION, node)), query, None)
1544
1545   def RepairNodeStorageUnits(self, node, storage_type, name):
1546     """Repairs a storage unit on the node.
1547
1548     @type node: str
1549     @param node: node whose storage units to repair
1550     @type storage_type: str
1551     @param storage_type: storage type to repair
1552     @type name: str
1553     @param name: name of the storage unit to repair
1554
1555     @rtype: string
1556     @return: job id
1557
1558     """
1559     query = [
1560       ("storage_type", storage_type),
1561       ("name", name),
1562       ]
1563
1564     return self._SendRequest(HTTP_PUT,
1565                              ("/%s/nodes/%s/storage/repair" %
1566                               (GANETI_RAPI_VERSION, node)), query, None)
1567
1568   def GetNodeTags(self, node):
1569     """Gets the tags for a node.
1570
1571     @type node: str
1572     @param node: node whose tags to return
1573
1574     @rtype: list of str
1575     @return: tags for the node
1576
1577     """
1578     return self._SendRequest(HTTP_GET,
1579                              ("/%s/nodes/%s/tags" %
1580                               (GANETI_RAPI_VERSION, node)), None, None)
1581
1582   def AddNodeTags(self, node, tags, dry_run=False):
1583     """Adds tags to a node.
1584
1585     @type node: str
1586     @param node: node to add tags to
1587     @type tags: list of str
1588     @param tags: tags to add to the node
1589     @type dry_run: bool
1590     @param dry_run: whether to perform a dry run
1591
1592     @rtype: string
1593     @return: job id
1594
1595     """
1596     query = [("tag", t) for t in tags]
1597     _AppendDryRunIf(query, dry_run)
1598
1599     return self._SendRequest(HTTP_PUT,
1600                              ("/%s/nodes/%s/tags" %
1601                               (GANETI_RAPI_VERSION, node)), query, tags)
1602
1603   def DeleteNodeTags(self, node, tags, dry_run=False):
1604     """Delete tags from a node.
1605
1606     @type node: str
1607     @param node: node to remove tags from
1608     @type tags: list of str
1609     @param tags: tags to remove from the node
1610     @type dry_run: bool
1611     @param dry_run: whether to perform a dry run
1612
1613     @rtype: string
1614     @return: job id
1615
1616     """
1617     query = [("tag", t) for t in tags]
1618     _AppendDryRunIf(query, dry_run)
1619
1620     return self._SendRequest(HTTP_DELETE,
1621                              ("/%s/nodes/%s/tags" %
1622                               (GANETI_RAPI_VERSION, node)), query, None)
1623
1624   def GetGroups(self, bulk=False):
1625     """Gets all node groups in the cluster.
1626
1627     @type bulk: bool
1628     @param bulk: whether to return all information about the groups
1629
1630     @rtype: list of dict or str
1631     @return: if bulk is true, a list of dictionaries with info about all node
1632         groups in the cluster, else a list of names of those node groups
1633
1634     """
1635     query = []
1636     _AppendIf(query, bulk, ("bulk", 1))
1637
1638     groups = self._SendRequest(HTTP_GET, "/%s/groups" % GANETI_RAPI_VERSION,
1639                                query, None)
1640     if bulk:
1641       return groups
1642     else:
1643       return [g["name"] for g in groups]
1644
1645   def GetGroup(self, group):
1646     """Gets information about a node group.
1647
1648     @type group: str
1649     @param group: name of the node group whose info to return
1650
1651     @rtype: dict
1652     @return: info about the node group
1653
1654     """
1655     return self._SendRequest(HTTP_GET,
1656                              "/%s/groups/%s" % (GANETI_RAPI_VERSION, group),
1657                              None, None)
1658
1659   def CreateGroup(self, name, alloc_policy=None, dry_run=False):
1660     """Creates a new node group.
1661
1662     @type name: str
1663     @param name: the name of node group to create
1664     @type alloc_policy: str
1665     @param alloc_policy: the desired allocation policy for the group, if any
1666     @type dry_run: bool
1667     @param dry_run: whether to peform a dry run
1668
1669     @rtype: string
1670     @return: job id
1671
1672     """
1673     query = []
1674     _AppendDryRunIf(query, dry_run)
1675
1676     body = {
1677       "name": name,
1678       "alloc_policy": alloc_policy
1679       }
1680
1681     return self._SendRequest(HTTP_POST, "/%s/groups" % GANETI_RAPI_VERSION,
1682                              query, body)
1683
1684   def ModifyGroup(self, group, **kwargs):
1685     """Modifies a node group.
1686
1687     More details for parameters can be found in the RAPI documentation.
1688
1689     @type group: string
1690     @param group: Node group name
1691     @rtype: string
1692     @return: job id
1693
1694     """
1695     return self._SendRequest(HTTP_PUT,
1696                              ("/%s/groups/%s/modify" %
1697                               (GANETI_RAPI_VERSION, group)), None, kwargs)
1698
1699   def DeleteGroup(self, group, dry_run=False):
1700     """Deletes a node group.
1701
1702     @type group: str
1703     @param group: the node group to delete
1704     @type dry_run: bool
1705     @param dry_run: whether to peform a dry run
1706
1707     @rtype: string
1708     @return: job id
1709
1710     """
1711     query = []
1712     _AppendDryRunIf(query, dry_run)
1713
1714     return self._SendRequest(HTTP_DELETE,
1715                              ("/%s/groups/%s" %
1716                               (GANETI_RAPI_VERSION, group)), query, None)
1717
1718   def RenameGroup(self, group, new_name):
1719     """Changes the name of a node group.
1720
1721     @type group: string
1722     @param group: Node group name
1723     @type new_name: string
1724     @param new_name: New node group name
1725
1726     @rtype: string
1727     @return: job id
1728
1729     """
1730     body = {
1731       "new_name": new_name,
1732       }
1733
1734     return self._SendRequest(HTTP_PUT,
1735                              ("/%s/groups/%s/rename" %
1736                               (GANETI_RAPI_VERSION, group)), None, body)
1737
1738   def AssignGroupNodes(self, group, nodes, force=False, dry_run=False):
1739     """Assigns nodes to a group.
1740
1741     @type group: string
1742     @param group: Node gropu name
1743     @type nodes: list of strings
1744     @param nodes: List of nodes to assign to the group
1745
1746     @rtype: string
1747     @return: job id
1748
1749     """
1750     query = []
1751     _AppendForceIf(query, force)
1752     _AppendDryRunIf(query, dry_run)
1753
1754     body = {
1755       "nodes": nodes,
1756       }
1757
1758     return self._SendRequest(HTTP_PUT,
1759                              ("/%s/groups/%s/assign-nodes" %
1760                              (GANETI_RAPI_VERSION, group)), query, body)
1761
1762   def GetGroupTags(self, group):
1763     """Gets tags for a node group.
1764
1765     @type group: string
1766     @param group: Node group whose tags to return
1767
1768     @rtype: list of strings
1769     @return: tags for the group
1770
1771     """
1772     return self._SendRequest(HTTP_GET,
1773                              ("/%s/groups/%s/tags" %
1774                               (GANETI_RAPI_VERSION, group)), None, None)
1775
1776   def AddGroupTags(self, group, tags, dry_run=False):
1777     """Adds tags to a node group.
1778
1779     @type group: str
1780     @param group: group to add tags to
1781     @type tags: list of string
1782     @param tags: tags to add to the group
1783     @type dry_run: bool
1784     @param dry_run: whether to perform a dry run
1785
1786     @rtype: string
1787     @return: job id
1788
1789     """
1790     query = [("tag", t) for t in tags]
1791     _AppendDryRunIf(query, dry_run)
1792
1793     return self._SendRequest(HTTP_PUT,
1794                              ("/%s/groups/%s/tags" %
1795                               (GANETI_RAPI_VERSION, group)), query, None)
1796
1797   def DeleteGroupTags(self, group, tags, dry_run=False):
1798     """Deletes tags from a node group.
1799
1800     @type group: str
1801     @param group: group to delete tags from
1802     @type tags: list of string
1803     @param tags: tags to delete
1804     @type dry_run: bool
1805     @param dry_run: whether to perform a dry run
1806     @rtype: string
1807     @return: job id
1808
1809     """
1810     query = [("tag", t) for t in tags]
1811     _AppendDryRunIf(query, dry_run)
1812
1813     return self._SendRequest(HTTP_DELETE,
1814                              ("/%s/groups/%s/tags" %
1815                               (GANETI_RAPI_VERSION, group)), query, None)
1816
1817   def Query(self, what, fields, qfilter=None):
1818     """Retrieves information about resources.
1819
1820     @type what: string
1821     @param what: Resource name, one of L{constants.QR_VIA_RAPI}
1822     @type fields: list of string
1823     @param fields: Requested fields
1824     @type qfilter: None or list
1825     @param qfilter: Query filter
1826
1827     @rtype: string
1828     @return: job id
1829
1830     """
1831     body = {
1832       "fields": fields,
1833       }
1834
1835     _SetItemIf(body, qfilter is not None, "qfilter", qfilter)
1836     # TODO: remove "filter" after 2.7
1837     _SetItemIf(body, qfilter is not None, "filter", qfilter)
1838
1839     return self._SendRequest(HTTP_PUT,
1840                              ("/%s/query/%s" %
1841                               (GANETI_RAPI_VERSION, what)), None, body)
1842
1843   def QueryFields(self, what, fields=None):
1844     """Retrieves available fields for a resource.
1845
1846     @type what: string
1847     @param what: Resource name, one of L{constants.QR_VIA_RAPI}
1848     @type fields: list of string
1849     @param fields: Requested fields
1850
1851     @rtype: string
1852     @return: job id
1853
1854     """
1855     query = []
1856
1857     if fields is not None:
1858       _AppendIf(query, True, ("fields", ",".join(fields)))
1859
1860     return self._SendRequest(HTTP_GET,
1861                              ("/%s/query/%s/fields" %
1862                               (GANETI_RAPI_VERSION, what)), query, None)