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