Fix a bug in command line option parsing code
[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 GrowInstanceDisk(self, instance, disk, amount, wait_for_sync=None):
761     """Grows a disk of an instance.
762
763     More details for parameters can be found in the RAPI documentation.
764
765     @type instance: string
766     @param instance: Instance name
767     @type disk: integer
768     @param disk: Disk index
769     @type amount: integer
770     @param amount: Grow disk by this amount (MiB)
771     @type wait_for_sync: bool
772     @param wait_for_sync: Wait for disk to synchronize
773     @rtype: string
774     @return: job id
775
776     """
777     body = {
778       "amount": amount,
779       }
780
781     if wait_for_sync is not None:
782       body["wait_for_sync"] = wait_for_sync
783
784     return self._SendRequest(HTTP_POST,
785                              ("/%s/instances/%s/disk/%s/grow" %
786                               (GANETI_RAPI_VERSION, instance, disk)),
787                              None, body)
788
789   def GetInstanceTags(self, instance):
790     """Gets tags for an instance.
791
792     @type instance: str
793     @param instance: instance whose tags to return
794
795     @rtype: list of str
796     @return: tags for the instance
797
798     """
799     return self._SendRequest(HTTP_GET,
800                              ("/%s/instances/%s/tags" %
801                               (GANETI_RAPI_VERSION, instance)), None, None)
802
803   def AddInstanceTags(self, instance, tags, dry_run=False):
804     """Adds tags to an instance.
805
806     @type instance: str
807     @param instance: instance to add tags to
808     @type tags: list of str
809     @param tags: tags to add to the instance
810     @type dry_run: bool
811     @param dry_run: whether to perform a dry run
812
813     @rtype: string
814     @return: job id
815
816     """
817     query = [("tag", t) for t in tags]
818     if dry_run:
819       query.append(("dry-run", 1))
820
821     return self._SendRequest(HTTP_PUT,
822                              ("/%s/instances/%s/tags" %
823                               (GANETI_RAPI_VERSION, instance)), query, None)
824
825   def DeleteInstanceTags(self, instance, tags, dry_run=False):
826     """Deletes tags from an instance.
827
828     @type instance: str
829     @param instance: instance to delete tags from
830     @type tags: list of str
831     @param tags: tags to delete
832     @type dry_run: bool
833     @param dry_run: whether to perform a dry run
834     @rtype: string
835     @return: job id
836
837     """
838     query = [("tag", t) for t in tags]
839     if dry_run:
840       query.append(("dry-run", 1))
841
842     return self._SendRequest(HTTP_DELETE,
843                              ("/%s/instances/%s/tags" %
844                               (GANETI_RAPI_VERSION, instance)), query, None)
845
846   def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
847                      dry_run=False):
848     """Reboots an instance.
849
850     @type instance: str
851     @param instance: instance to rebot
852     @type reboot_type: str
853     @param reboot_type: one of: hard, soft, full
854     @type ignore_secondaries: bool
855     @param ignore_secondaries: if True, ignores errors for the secondary node
856         while re-assembling disks (in hard-reboot mode only)
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 = []
864     if reboot_type:
865       query.append(("type", reboot_type))
866     if ignore_secondaries is not None:
867       query.append(("ignore_secondaries", ignore_secondaries))
868     if dry_run:
869       query.append(("dry-run", 1))
870
871     return self._SendRequest(HTTP_POST,
872                              ("/%s/instances/%s/reboot" %
873                               (GANETI_RAPI_VERSION, instance)), query, None)
874
875   def ShutdownInstance(self, instance, dry_run=False, no_remember=False):
876     """Shuts down an instance.
877
878     @type instance: str
879     @param instance: the instance to shut down
880     @type dry_run: bool
881     @param dry_run: whether to perform a dry run
882     @type no_remember: bool
883     @param no_remember: if true, will not record the state change
884     @rtype: string
885     @return: job id
886
887     """
888     query = []
889     if dry_run:
890       query.append(("dry-run", 1))
891     if no_remember:
892       query.append(("no-remember", 1))
893
894     return self._SendRequest(HTTP_PUT,
895                              ("/%s/instances/%s/shutdown" %
896                               (GANETI_RAPI_VERSION, instance)), query, None)
897
898   def StartupInstance(self, instance, dry_run=False, no_remember=False):
899     """Starts up an instance.
900
901     @type instance: str
902     @param instance: the instance to start up
903     @type dry_run: bool
904     @param dry_run: whether to perform a dry run
905     @type no_remember: bool
906     @param no_remember: if true, will not record the state change
907     @rtype: string
908     @return: job id
909
910     """
911     query = []
912     if dry_run:
913       query.append(("dry-run", 1))
914     if no_remember:
915       query.append(("no-remember", 1))
916
917     return self._SendRequest(HTTP_PUT,
918                              ("/%s/instances/%s/startup" %
919                               (GANETI_RAPI_VERSION, instance)), query, None)
920
921   def ReinstallInstance(self, instance, os=None, no_startup=False,
922                         osparams=None):
923     """Reinstalls an instance.
924
925     @type instance: str
926     @param instance: The instance to reinstall
927     @type os: str or None
928     @param os: The operating system to reinstall. If None, the instance's
929         current operating system will be installed again
930     @type no_startup: bool
931     @param no_startup: Whether to start the instance automatically
932     @rtype: string
933     @return: job id
934
935     """
936     if _INST_REINSTALL_REQV1 in self.GetFeatures():
937       body = {
938         "start": not no_startup,
939         }
940       if os is not None:
941         body["os"] = os
942       if osparams is not None:
943         body["osparams"] = osparams
944       return self._SendRequest(HTTP_POST,
945                                ("/%s/instances/%s/reinstall" %
946                                 (GANETI_RAPI_VERSION, instance)), None, body)
947
948     # Use old request format
949     if osparams:
950       raise GanetiApiError("Server does not support specifying OS parameters"
951                            " for instance reinstallation")
952
953     query = []
954     if os:
955       query.append(("os", os))
956     if no_startup:
957       query.append(("nostartup", 1))
958     return self._SendRequest(HTTP_POST,
959                              ("/%s/instances/%s/reinstall" %
960                               (GANETI_RAPI_VERSION, instance)), query, None)
961
962   def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
963                            remote_node=None, iallocator=None):
964     """Replaces disks on an instance.
965
966     @type instance: str
967     @param instance: instance whose disks to replace
968     @type disks: list of ints
969     @param disks: Indexes of disks to replace
970     @type mode: str
971     @param mode: replacement mode to use (defaults to replace_auto)
972     @type remote_node: str or None
973     @param remote_node: new secondary node to use (for use with
974         replace_new_secondary mode)
975     @type iallocator: str or None
976     @param iallocator: instance allocator plugin to use (for use with
977                        replace_auto mode)
978
979     @rtype: string
980     @return: job id
981
982     """
983     query = [
984       ("mode", mode),
985       ]
986
987     # TODO: Convert to body parameters
988
989     if disks is not None:
990       query.append(("disks", ",".join(str(idx) for idx in disks)))
991
992     if remote_node is not None:
993       query.append(("remote_node", remote_node))
994
995     if iallocator is not None:
996       query.append(("iallocator", iallocator))
997
998     return self._SendRequest(HTTP_POST,
999                              ("/%s/instances/%s/replace-disks" %
1000                               (GANETI_RAPI_VERSION, instance)), query, None)
1001
1002   def PrepareExport(self, instance, mode):
1003     """Prepares an instance for an export.
1004
1005     @type instance: string
1006     @param instance: Instance name
1007     @type mode: string
1008     @param mode: Export mode
1009     @rtype: string
1010     @return: Job ID
1011
1012     """
1013     query = [("mode", mode)]
1014     return self._SendRequest(HTTP_PUT,
1015                              ("/%s/instances/%s/prepare-export" %
1016                               (GANETI_RAPI_VERSION, instance)), query, None)
1017
1018   def ExportInstance(self, instance, mode, destination, shutdown=None,
1019                      remove_instance=None,
1020                      x509_key_name=None, destination_x509_ca=None):
1021     """Exports an instance.
1022
1023     @type instance: string
1024     @param instance: Instance name
1025     @type mode: string
1026     @param mode: Export mode
1027     @rtype: string
1028     @return: Job ID
1029
1030     """
1031     body = {
1032       "destination": destination,
1033       "mode": mode,
1034       }
1035
1036     if shutdown is not None:
1037       body["shutdown"] = shutdown
1038
1039     if remove_instance is not None:
1040       body["remove_instance"] = remove_instance
1041
1042     if x509_key_name is not None:
1043       body["x509_key_name"] = x509_key_name
1044
1045     if destination_x509_ca is not None:
1046       body["destination_x509_ca"] = destination_x509_ca
1047
1048     return self._SendRequest(HTTP_PUT,
1049                              ("/%s/instances/%s/export" %
1050                               (GANETI_RAPI_VERSION, instance)), None, body)
1051
1052   def MigrateInstance(self, instance, mode=None, cleanup=None):
1053     """Migrates an instance.
1054
1055     @type instance: string
1056     @param instance: Instance name
1057     @type mode: string
1058     @param mode: Migration mode
1059     @type cleanup: bool
1060     @param cleanup: Whether to clean up a previously failed migration
1061     @rtype: string
1062     @return: job id
1063
1064     """
1065     body = {}
1066
1067     if mode is not None:
1068       body["mode"] = mode
1069
1070     if cleanup is not None:
1071       body["cleanup"] = cleanup
1072
1073     return self._SendRequest(HTTP_PUT,
1074                              ("/%s/instances/%s/migrate" %
1075                               (GANETI_RAPI_VERSION, instance)), None, body)
1076
1077   def FailoverInstance(self, instance, iallocator=None,
1078                        ignore_consistency=None, target_node=None):
1079     """Does a failover of an instance.
1080
1081     @type instance: string
1082     @param instance: Instance name
1083     @type iallocator: string
1084     @param iallocator: Iallocator for deciding the target node for
1085       shared-storage instances
1086     @type ignore_consistency: bool
1087     @param ignore_consistency: Whether to ignore disk consistency
1088     @type target_node: string
1089     @param target_node: Target node for shared-storage instances
1090     @rtype: string
1091     @return: job id
1092
1093     """
1094     body = {}
1095
1096     if iallocator is not None:
1097       body["iallocator"] = iallocator
1098
1099     if ignore_consistency is not None:
1100       body["ignore_consistency"] = ignore_consistency
1101
1102     if target_node is not None:
1103       body["target_node"] = target_node
1104
1105     return self._SendRequest(HTTP_PUT,
1106                              ("/%s/instances/%s/failover" %
1107                               (GANETI_RAPI_VERSION, instance)), None, body)
1108
1109   def RenameInstance(self, instance, new_name, ip_check=None, name_check=None):
1110     """Changes the name of an instance.
1111
1112     @type instance: string
1113     @param instance: Instance name
1114     @type new_name: string
1115     @param new_name: New instance name
1116     @type ip_check: bool
1117     @param ip_check: Whether to ensure instance's IP address is inactive
1118     @type name_check: bool
1119     @param name_check: Whether to ensure instance's name is resolvable
1120     @rtype: string
1121     @return: job id
1122
1123     """
1124     body = {
1125       "new_name": new_name,
1126       }
1127
1128     if ip_check is not None:
1129       body["ip_check"] = ip_check
1130
1131     if name_check is not None:
1132       body["name_check"] = name_check
1133
1134     return self._SendRequest(HTTP_PUT,
1135                              ("/%s/instances/%s/rename" %
1136                               (GANETI_RAPI_VERSION, instance)), None, body)
1137
1138   def GetInstanceConsole(self, instance):
1139     """Request information for connecting to instance's console.
1140
1141     @type instance: string
1142     @param instance: Instance name
1143     @rtype: dict
1144     @return: dictionary containing information about instance's console
1145
1146     """
1147     return self._SendRequest(HTTP_GET,
1148                              ("/%s/instances/%s/console" %
1149                               (GANETI_RAPI_VERSION, instance)), None, None)
1150
1151   def GetJobs(self):
1152     """Gets all jobs for the cluster.
1153
1154     @rtype: list of int
1155     @return: job ids for the cluster
1156
1157     """
1158     return [int(j["id"])
1159             for j in self._SendRequest(HTTP_GET,
1160                                        "/%s/jobs" % GANETI_RAPI_VERSION,
1161                                        None, None)]
1162
1163   def GetJobStatus(self, job_id):
1164     """Gets the status of a job.
1165
1166     @type job_id: string
1167     @param job_id: job id whose status to query
1168
1169     @rtype: dict
1170     @return: job status
1171
1172     """
1173     return self._SendRequest(HTTP_GET,
1174                              "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
1175                              None, None)
1176
1177   def WaitForJobCompletion(self, job_id, period=5, retries=-1):
1178     """Polls cluster for job status until completion.
1179
1180     Completion is defined as any of the following states listed in
1181     L{JOB_STATUS_FINALIZED}.
1182
1183     @type job_id: string
1184     @param job_id: job id to watch
1185     @type period: int
1186     @param period: how often to poll for status (optional, default 5s)
1187     @type retries: int
1188     @param retries: how many time to poll before giving up
1189                     (optional, default -1 means unlimited)
1190
1191     @rtype: bool
1192     @return: C{True} if job succeeded or C{False} if failed/status timeout
1193     @deprecated: It is recommended to use L{WaitForJobChange} wherever
1194       possible; L{WaitForJobChange} returns immediately after a job changed and
1195       does not use polling
1196
1197     """
1198     while retries != 0:
1199       job_result = self.GetJobStatus(job_id)
1200
1201       if job_result and job_result["status"] == JOB_STATUS_SUCCESS:
1202         return True
1203       elif not job_result or job_result["status"] in JOB_STATUS_FINALIZED:
1204         return False
1205
1206       if period:
1207         time.sleep(period)
1208
1209       if retries > 0:
1210         retries -= 1
1211
1212     return False
1213
1214   def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
1215     """Waits for job changes.
1216
1217     @type job_id: string
1218     @param job_id: Job ID for which to wait
1219     @return: C{None} if no changes have been detected and a dict with two keys,
1220       C{job_info} and C{log_entries} otherwise.
1221     @rtype: dict
1222
1223     """
1224     body = {
1225       "fields": fields,
1226       "previous_job_info": prev_job_info,
1227       "previous_log_serial": prev_log_serial,
1228       }
1229
1230     return self._SendRequest(HTTP_GET,
1231                              "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
1232                              None, body)
1233
1234   def CancelJob(self, job_id, dry_run=False):
1235     """Cancels a job.
1236
1237     @type job_id: string
1238     @param job_id: id of the job to delete
1239     @type dry_run: bool
1240     @param dry_run: whether to perform a dry run
1241     @rtype: tuple
1242     @return: tuple containing the result, and a message (bool, string)
1243
1244     """
1245     query = []
1246     if dry_run:
1247       query.append(("dry-run", 1))
1248
1249     return self._SendRequest(HTTP_DELETE,
1250                              "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
1251                              query, None)
1252
1253   def GetNodes(self, bulk=False):
1254     """Gets all nodes in the cluster.
1255
1256     @type bulk: bool
1257     @param bulk: whether to return all information about all instances
1258
1259     @rtype: list of dict or str
1260     @return: if bulk is true, info about nodes in the cluster,
1261         else list of nodes in the cluster
1262
1263     """
1264     query = []
1265     if bulk:
1266       query.append(("bulk", 1))
1267
1268     nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
1269                               query, None)
1270     if bulk:
1271       return nodes
1272     else:
1273       return [n["id"] for n in nodes]
1274
1275   def GetNode(self, node):
1276     """Gets information about a node.
1277
1278     @type node: str
1279     @param node: node whose info to return
1280
1281     @rtype: dict
1282     @return: info about the node
1283
1284     """
1285     return self._SendRequest(HTTP_GET,
1286                              "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
1287                              None, None)
1288
1289   def EvacuateNode(self, node, iallocator=None, remote_node=None,
1290                    dry_run=False, early_release=None,
1291                    mode=None, accept_old=False):
1292     """Evacuates instances from a Ganeti node.
1293
1294     @type node: str
1295     @param node: node to evacuate
1296     @type iallocator: str or None
1297     @param iallocator: instance allocator to use
1298     @type remote_node: str
1299     @param remote_node: node to evaucate to
1300     @type dry_run: bool
1301     @param dry_run: whether to perform a dry run
1302     @type early_release: bool
1303     @param early_release: whether to enable parallelization
1304     @type mode: string
1305     @param mode: Node evacuation mode
1306     @type accept_old: bool
1307     @param accept_old: Whether caller is ready to accept old-style (pre-2.5)
1308         results
1309
1310     @rtype: string, or a list for pre-2.5 results
1311     @return: Job ID or, if C{accept_old} is set and server is pre-2.5,
1312       list of (job ID, instance name, new secondary node); if dry_run was
1313       specified, then the actual move jobs were not submitted and the job IDs
1314       will be C{None}
1315
1316     @raises GanetiApiError: if an iallocator and remote_node are both
1317         specified
1318
1319     """
1320     if iallocator and remote_node:
1321       raise GanetiApiError("Only one of iallocator or remote_node can be used")
1322
1323     query = []
1324     if dry_run:
1325       query.append(("dry-run", 1))
1326
1327     if _NODE_EVAC_RES1 in self.GetFeatures():
1328       # Server supports body parameters
1329       body = {}
1330
1331       if iallocator is not None:
1332         body["iallocator"] = iallocator
1333       if remote_node is not None:
1334         body["remote_node"] = remote_node
1335       if early_release is not None:
1336         body["early_release"] = early_release
1337       if mode is not None:
1338         body["mode"] = mode
1339     else:
1340       # Pre-2.5 request format
1341       body = None
1342
1343       if not accept_old:
1344         raise GanetiApiError("Server is version 2.4 or earlier and caller does"
1345                              " not accept old-style results (parameter"
1346                              " accept_old)")
1347
1348       # Pre-2.5 servers can only evacuate secondaries
1349       if mode is not None and mode != NODE_EVAC_SEC:
1350         raise GanetiApiError("Server can only evacuate secondary instances")
1351
1352       if iallocator:
1353         query.append(("iallocator", iallocator))
1354       if remote_node:
1355         query.append(("remote_node", remote_node))
1356       if early_release:
1357         query.append(("early_release", 1))
1358
1359     return self._SendRequest(HTTP_POST,
1360                              ("/%s/nodes/%s/evacuate" %
1361                               (GANETI_RAPI_VERSION, node)), query, body)
1362
1363   def MigrateNode(self, node, mode=None, dry_run=False, iallocator=None,
1364                   target_node=None):
1365     """Migrates all primary instances from a node.
1366
1367     @type node: str
1368     @param node: node to migrate
1369     @type mode: string
1370     @param mode: if passed, it will overwrite the live migration type,
1371         otherwise the hypervisor default will be used
1372     @type dry_run: bool
1373     @param dry_run: whether to perform a dry run
1374     @type iallocator: string
1375     @param iallocator: instance allocator to use
1376     @type target_node: string
1377     @param target_node: Target node for shared-storage instances
1378
1379     @rtype: string
1380     @return: job id
1381
1382     """
1383     query = []
1384     if dry_run:
1385       query.append(("dry-run", 1))
1386
1387     if _NODE_MIGRATE_REQV1 in self.GetFeatures():
1388       body = {}
1389
1390       if mode is not None:
1391         body["mode"] = mode
1392       if iallocator is not None:
1393         body["iallocator"] = iallocator
1394       if target_node is not None:
1395         body["target_node"] = target_node
1396
1397       assert len(query) <= 1
1398
1399       return self._SendRequest(HTTP_POST,
1400                                ("/%s/nodes/%s/migrate" %
1401                                 (GANETI_RAPI_VERSION, node)), query, body)
1402     else:
1403       # Use old request format
1404       if target_node is not None:
1405         raise GanetiApiError("Server does not support specifying target node"
1406                              " for node migration")
1407
1408       if mode is not None:
1409         query.append(("mode", mode))
1410
1411       return self._SendRequest(HTTP_POST,
1412                                ("/%s/nodes/%s/migrate" %
1413                                 (GANETI_RAPI_VERSION, node)), query, None)
1414
1415   def GetNodeRole(self, node):
1416     """Gets the current role for a node.
1417
1418     @type node: str
1419     @param node: node whose role to return
1420
1421     @rtype: str
1422     @return: the current role for a node
1423
1424     """
1425     return self._SendRequest(HTTP_GET,
1426                              ("/%s/nodes/%s/role" %
1427                               (GANETI_RAPI_VERSION, node)), None, None)
1428
1429   def SetNodeRole(self, node, role, force=False, auto_promote=None):
1430     """Sets the role for a node.
1431
1432     @type node: str
1433     @param node: the node whose role to set
1434     @type role: str
1435     @param role: the role to set for the node
1436     @type force: bool
1437     @param force: whether to force the role change
1438     @type auto_promote: bool
1439     @param auto_promote: Whether node(s) should be promoted to master candidate
1440                          if necessary
1441
1442     @rtype: string
1443     @return: job id
1444
1445     """
1446     query = [
1447       ("force", force),
1448       ]
1449
1450     if auto_promote is not None:
1451       query.append(("auto-promote", auto_promote))
1452
1453     return self._SendRequest(HTTP_PUT,
1454                              ("/%s/nodes/%s/role" %
1455                               (GANETI_RAPI_VERSION, node)), query, role)
1456
1457   def ModifyNode(self, node, **kwargs):
1458     """Modifies a node.
1459
1460     More details for parameters can be found in the RAPI documentation.
1461
1462     @type node: string
1463     @param node: Node name
1464     @rtype: string
1465     @return: job id
1466
1467     """
1468     return self._SendRequest(HTTP_POST,
1469                              ("/%s/nodes/%s/modify" %
1470                               (GANETI_RAPI_VERSION, node)), None, kwargs)
1471
1472   def GetNodeStorageUnits(self, node, storage_type, output_fields):
1473     """Gets the storage units for a node.
1474
1475     @type node: str
1476     @param node: the node whose storage units to return
1477     @type storage_type: str
1478     @param storage_type: storage type whose units to return
1479     @type output_fields: str
1480     @param output_fields: storage type fields to return
1481
1482     @rtype: string
1483     @return: job id where results can be retrieved
1484
1485     """
1486     query = [
1487       ("storage_type", storage_type),
1488       ("output_fields", output_fields),
1489       ]
1490
1491     return self._SendRequest(HTTP_GET,
1492                              ("/%s/nodes/%s/storage" %
1493                               (GANETI_RAPI_VERSION, node)), query, None)
1494
1495   def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
1496     """Modifies parameters of storage units on the node.
1497
1498     @type node: str
1499     @param node: node whose storage units to modify
1500     @type storage_type: str
1501     @param storage_type: storage type whose units to modify
1502     @type name: str
1503     @param name: name of the storage unit
1504     @type allocatable: bool or None
1505     @param allocatable: Whether to set the "allocatable" flag on the storage
1506                         unit (None=no modification, True=set, False=unset)
1507
1508     @rtype: string
1509     @return: job id
1510
1511     """
1512     query = [
1513       ("storage_type", storage_type),
1514       ("name", name),
1515       ]
1516
1517     if allocatable is not None:
1518       query.append(("allocatable", allocatable))
1519
1520     return self._SendRequest(HTTP_PUT,
1521                              ("/%s/nodes/%s/storage/modify" %
1522                               (GANETI_RAPI_VERSION, node)), query, None)
1523
1524   def RepairNodeStorageUnits(self, node, storage_type, name):
1525     """Repairs a storage unit on the node.
1526
1527     @type node: str
1528     @param node: node whose storage units to repair
1529     @type storage_type: str
1530     @param storage_type: storage type to repair
1531     @type name: str
1532     @param name: name of the storage unit to repair
1533
1534     @rtype: string
1535     @return: job id
1536
1537     """
1538     query = [
1539       ("storage_type", storage_type),
1540       ("name", name),
1541       ]
1542
1543     return self._SendRequest(HTTP_PUT,
1544                              ("/%s/nodes/%s/storage/repair" %
1545                               (GANETI_RAPI_VERSION, node)), query, None)
1546
1547   def GetNodeTags(self, node):
1548     """Gets the tags for a node.
1549
1550     @type node: str
1551     @param node: node whose tags to return
1552
1553     @rtype: list of str
1554     @return: tags for the node
1555
1556     """
1557     return self._SendRequest(HTTP_GET,
1558                              ("/%s/nodes/%s/tags" %
1559                               (GANETI_RAPI_VERSION, node)), None, None)
1560
1561   def AddNodeTags(self, node, tags, dry_run=False):
1562     """Adds tags to a node.
1563
1564     @type node: str
1565     @param node: node to add tags to
1566     @type tags: list of str
1567     @param tags: tags to add to the node
1568     @type dry_run: bool
1569     @param dry_run: whether to perform a dry run
1570
1571     @rtype: string
1572     @return: job id
1573
1574     """
1575     query = [("tag", t) for t in tags]
1576     if dry_run:
1577       query.append(("dry-run", 1))
1578
1579     return self._SendRequest(HTTP_PUT,
1580                              ("/%s/nodes/%s/tags" %
1581                               (GANETI_RAPI_VERSION, node)), query, tags)
1582
1583   def DeleteNodeTags(self, node, tags, dry_run=False):
1584     """Delete tags from a node.
1585
1586     @type node: str
1587     @param node: node to remove tags from
1588     @type tags: list of str
1589     @param tags: tags to remove from the node
1590     @type dry_run: bool
1591     @param dry_run: whether to perform a dry run
1592
1593     @rtype: string
1594     @return: job id
1595
1596     """
1597     query = [("tag", t) for t in tags]
1598     if dry_run:
1599       query.append(("dry-run", 1))
1600
1601     return self._SendRequest(HTTP_DELETE,
1602                              ("/%s/nodes/%s/tags" %
1603                               (GANETI_RAPI_VERSION, node)), query, None)
1604
1605   def GetGroups(self, bulk=False):
1606     """Gets all node groups in the cluster.
1607
1608     @type bulk: bool
1609     @param bulk: whether to return all information about the groups
1610
1611     @rtype: list of dict or str
1612     @return: if bulk is true, a list of dictionaries with info about all node
1613         groups in the cluster, else a list of names of those node groups
1614
1615     """
1616     query = []
1617     if bulk:
1618       query.append(("bulk", 1))
1619
1620     groups = self._SendRequest(HTTP_GET, "/%s/groups" % GANETI_RAPI_VERSION,
1621                                query, None)
1622     if bulk:
1623       return groups
1624     else:
1625       return [g["name"] for g in groups]
1626
1627   def GetGroup(self, group):
1628     """Gets information about a node group.
1629
1630     @type group: str
1631     @param group: name of the node group whose info to return
1632
1633     @rtype: dict
1634     @return: info about the node group
1635
1636     """
1637     return self._SendRequest(HTTP_GET,
1638                              "/%s/groups/%s" % (GANETI_RAPI_VERSION, group),
1639                              None, None)
1640
1641   def CreateGroup(self, name, alloc_policy=None, dry_run=False):
1642     """Creates a new node group.
1643
1644     @type name: str
1645     @param name: the name of node group to create
1646     @type alloc_policy: str
1647     @param alloc_policy: the desired allocation policy for the group, if any
1648     @type dry_run: bool
1649     @param dry_run: whether to peform a dry run
1650
1651     @rtype: string
1652     @return: job id
1653
1654     """
1655     query = []
1656     if dry_run:
1657       query.append(("dry-run", 1))
1658
1659     body = {
1660       "name": name,
1661       "alloc_policy": alloc_policy
1662       }
1663
1664     return self._SendRequest(HTTP_POST, "/%s/groups" % GANETI_RAPI_VERSION,
1665                              query, body)
1666
1667   def ModifyGroup(self, group, **kwargs):
1668     """Modifies a node group.
1669
1670     More details for parameters can be found in the RAPI documentation.
1671
1672     @type group: string
1673     @param group: Node group name
1674     @rtype: string
1675     @return: job id
1676
1677     """
1678     return self._SendRequest(HTTP_PUT,
1679                              ("/%s/groups/%s/modify" %
1680                               (GANETI_RAPI_VERSION, group)), None, kwargs)
1681
1682   def DeleteGroup(self, group, dry_run=False):
1683     """Deletes a node group.
1684
1685     @type group: str
1686     @param group: the node group to delete
1687     @type dry_run: bool
1688     @param dry_run: whether to peform a dry run
1689
1690     @rtype: string
1691     @return: job id
1692
1693     """
1694     query = []
1695     if dry_run:
1696       query.append(("dry-run", 1))
1697
1698     return self._SendRequest(HTTP_DELETE,
1699                              ("/%s/groups/%s" %
1700                               (GANETI_RAPI_VERSION, group)), query, None)
1701
1702   def RenameGroup(self, group, new_name):
1703     """Changes the name of a node group.
1704
1705     @type group: string
1706     @param group: Node group name
1707     @type new_name: string
1708     @param new_name: New node group name
1709
1710     @rtype: string
1711     @return: job id
1712
1713     """
1714     body = {
1715       "new_name": new_name,
1716       }
1717
1718     return self._SendRequest(HTTP_PUT,
1719                              ("/%s/groups/%s/rename" %
1720                               (GANETI_RAPI_VERSION, group)), None, body)
1721
1722   def AssignGroupNodes(self, group, nodes, force=False, dry_run=False):
1723     """Assigns nodes to a group.
1724
1725     @type group: string
1726     @param group: Node gropu name
1727     @type nodes: list of strings
1728     @param nodes: List of nodes to assign to the group
1729
1730     @rtype: string
1731     @return: job id
1732
1733     """
1734     query = []
1735
1736     if force:
1737       query.append(("force", 1))
1738
1739     if dry_run:
1740       query.append(("dry-run", 1))
1741
1742     body = {
1743       "nodes": nodes,
1744       }
1745
1746     return self._SendRequest(HTTP_PUT,
1747                              ("/%s/groups/%s/assign-nodes" %
1748                              (GANETI_RAPI_VERSION, group)), query, body)
1749
1750   def GetGroupTags(self, group):
1751     """Gets tags for a node group.
1752
1753     @type group: string
1754     @param group: Node group whose tags to return
1755
1756     @rtype: list of strings
1757     @return: tags for the group
1758
1759     """
1760     return self._SendRequest(HTTP_GET,
1761                              ("/%s/groups/%s/tags" %
1762                               (GANETI_RAPI_VERSION, group)), None, None)
1763
1764   def AddGroupTags(self, group, tags, dry_run=False):
1765     """Adds tags to a node group.
1766
1767     @type group: str
1768     @param group: group to add tags to
1769     @type tags: list of string
1770     @param tags: tags to add to the group
1771     @type dry_run: bool
1772     @param dry_run: whether to perform a dry run
1773
1774     @rtype: string
1775     @return: job id
1776
1777     """
1778     query = [("tag", t) for t in tags]
1779     if dry_run:
1780       query.append(("dry-run", 1))
1781
1782     return self._SendRequest(HTTP_PUT,
1783                              ("/%s/groups/%s/tags" %
1784                               (GANETI_RAPI_VERSION, group)), query, None)
1785
1786   def DeleteGroupTags(self, group, tags, dry_run=False):
1787     """Deletes tags from a node group.
1788
1789     @type group: str
1790     @param group: group to delete tags from
1791     @type tags: list of string
1792     @param tags: tags to delete
1793     @type dry_run: bool
1794     @param dry_run: whether to perform a dry run
1795     @rtype: string
1796     @return: job id
1797
1798     """
1799     query = [("tag", t) for t in tags]
1800     if dry_run:
1801       query.append(("dry-run", 1))
1802
1803     return self._SendRequest(HTTP_DELETE,
1804                              ("/%s/groups/%s/tags" %
1805                               (GANETI_RAPI_VERSION, group)), query, None)
1806
1807   def Query(self, what, fields, filter_=None):
1808     """Retrieves information about resources.
1809
1810     @type what: string
1811     @param what: Resource name, one of L{constants.QR_VIA_RAPI}
1812     @type fields: list of string
1813     @param fields: Requested fields
1814     @type filter_: None or list
1815     @param filter_: Query filter
1816
1817     @rtype: string
1818     @return: job id
1819
1820     """
1821     body = {
1822       "fields": fields,
1823       }
1824
1825     if filter_ is not None:
1826       body["filter"] = filter_
1827
1828     return self._SendRequest(HTTP_PUT,
1829                              ("/%s/query/%s" %
1830                               (GANETI_RAPI_VERSION, what)), None, body)
1831
1832   def QueryFields(self, what, fields=None):
1833     """Retrieves available fields for a resource.
1834
1835     @type what: string
1836     @param what: Resource name, one of L{constants.QR_VIA_RAPI}
1837     @type fields: list of string
1838     @param fields: Requested fields
1839
1840     @rtype: string
1841     @return: job id
1842
1843     """
1844     query = []
1845
1846     if fields is not None:
1847       query.append(("fields", ",".join(fields)))
1848
1849     return self._SendRequest(HTTP_GET,
1850                              ("/%s/query/%s/fields" %
1851                               (GANETI_RAPI_VERSION, what)), query, None)