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