4 # Copyright (C) 2010 Google Inc.
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.
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.
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
22 """Ganeti RAPI client."""
31 import distutils.version
34 GANETI_RAPI_PORT = 5080
35 GANETI_RAPI_VERSION = 2
37 HTTP_DELETE = "DELETE"
42 HTTP_APP_JSON = "application/json"
44 REPLACE_DISK_PRI = "replace_on_primary"
45 REPLACE_DISK_SECONDARY = "replace_on_secondary"
46 REPLACE_DISK_CHG = "replace_new_secondary"
47 REPLACE_DISK_AUTO = "replace_auto"
49 NODE_ROLE_DRAINED = "drained"
50 NODE_ROLE_MASTER_CANDIATE = "master-candidate"
51 NODE_ROLE_MASTER = "master"
52 NODE_ROLE_OFFLINE = "offline"
53 NODE_ROLE_REGULAR = "regular"
56 class Error(Exception):
57 """Base error class for this module.
63 class CertificateError(Error):
64 """Raised when a problem is found with the SSL certificate.
70 class GanetiApiError(Error):
71 """Generic error raised from Ganeti API.
74 def __init__(self, msg, code=None):
75 Error.__init__(self, msg)
79 def FormatX509Name(x509_name):
80 """Formats an X509 name.
82 @type x509_name: OpenSSL.crypto.X509Name
86 # Only supported in pyOpenSSL 0.7 and above
87 get_components_fn = x509_name.get_components
88 except AttributeError:
89 return repr(x509_name)
91 return "".join("/%s=%s" % (name, value)
92 for name, value in get_components_fn())
95 class CertAuthorityVerify:
96 """Certificate verificator for SSL context.
98 Configures SSL context to verify server's certificate.
101 _CAPATH_MINVERSION = "0.9"
102 _DEFVFYPATHS_MINVERSION = "0.9"
104 _PYOPENSSL_VERSION = OpenSSL.__version__
105 _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
107 _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
108 _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
110 def __init__(self, cafile=None, capath=None, use_default_verify_paths=False):
111 """Initializes this class.
114 @param cafile: In which file we can find the certificates
116 @param capath: In which directory we can find the certificates
117 @type use_default_verify_paths: bool
118 @param use_default_verify_paths: Whether the platform provided CA
119 certificates are to be used for
120 verification purposes
123 self._cafile = cafile
124 self._capath = capath
125 self._use_default_verify_paths = use_default_verify_paths
127 if self._capath is not None and not self._SUPPORT_CAPATH:
128 raise Error(("PyOpenSSL %s has no support for a CA directory,"
129 " version %s or above is required") %
130 (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION))
132 if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS:
133 raise Error(("PyOpenSSL %s has no support for using default verification"
134 " paths, version %s or above is required") %
135 (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION))
138 def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok):
139 """Callback for SSL certificate verification.
141 @param logger: Logging object
145 log_fn = logger.debug
147 log_fn = logger.error
149 log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'",
150 errdepth, FormatX509Name(cert.get_subject()),
151 FormatX509Name(cert.get_issuer()))
155 # Only supported in pyOpenSSL 0.7 and above
156 # pylint: disable-msg=E1101
157 fn = OpenSSL.crypto.X509_verify_cert_error_string
158 except AttributeError:
161 errmsg = ":%s" % fn(errnum)
163 logger.error("verify error:num=%s%s", errnum, errmsg)
167 def __call__(self, ctx, logger):
168 """Configures an SSL context to verify certificates.
170 @type ctx: OpenSSL.SSL.Context
171 @param ctx: SSL context
174 if self._use_default_verify_paths:
175 ctx.set_default_verify_paths()
177 if self._cafile or self._capath:
178 if self._SUPPORT_CAPATH:
179 ctx.load_verify_locations(self._cafile, self._capath)
181 ctx.load_verify_locations(self._cafile)
183 ctx.set_verify(OpenSSL.SSL.VERIFY_PEER,
184 lambda conn, cert, errnum, errdepth, ok: \
185 self._VerifySslCertCb(logger, conn, cert,
186 errnum, errdepth, ok))
189 class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
190 """HTTPS Connection handler that verifies the SSL certificate.
193 def __init__(self, *args, **kwargs):
194 """Initializes this class.
197 httplib.HTTPSConnection.__init__(self, *args, **kwargs)
199 self._config_ssl_verification = None
201 def Setup(self, logger, config_ssl_verification):
202 """Sets the SSL verification config function.
204 @param logger: Logging object
205 @type config_ssl_verification: callable
208 assert self._logger is None
209 assert self._config_ssl_verification is None
211 self._logger = logger
212 self._config_ssl_verification = config_ssl_verification
215 """Connect to the server specified when the object was created.
217 This ensures that SSL certificates are verified.
220 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
222 ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
223 ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
225 if self._config_ssl_verification:
226 self._config_ssl_verification(ctx, self._logger)
228 ssl = OpenSSL.SSL.Connection(ctx, sock)
229 ssl.connect((self.host, self.port))
231 self.sock = httplib.FakeSocket(sock, ssl)
234 class _HTTPSHandler(urllib2.HTTPSHandler):
235 def __init__(self, logger, config_ssl_verification):
236 """Initializes this class.
238 @param logger: Logging object
239 @type config_ssl_verification: callable
240 @param config_ssl_verification: Function to configure SSL context for
241 certificate verification
244 urllib2.HTTPSHandler.__init__(self)
245 self._logger = logger
246 self._config_ssl_verification = config_ssl_verification
248 def _CreateHttpsConnection(self, *args, **kwargs):
249 """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification.
251 This wrapper is necessary provide a compatible API to urllib2.
254 conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
255 conn.Setup(self._logger, self._config_ssl_verification)
258 def https_open(self, req):
259 """Creates HTTPS connection.
264 return self.do_open(self._CreateHttpsConnection, req)
267 class _RapiRequest(urllib2.Request):
268 def __init__(self, method, url, headers, data):
269 """Initializes this class.
272 urllib2.Request.__init__(self, url, data=data, headers=headers)
273 self._method = method
275 def get_method(self):
276 """Returns the HTTP request method.
282 class GanetiRapiClient(object):
283 """Ganeti RAPI client.
286 USER_AGENT = "Ganeti RAPI Client"
287 _json_encoder = simplejson.JSONEncoder(sort_keys=True)
289 def __init__(self, host, port=GANETI_RAPI_PORT,
290 username=None, password=None,
291 config_ssl_verification=None, ignore_proxy=False,
296 @param host: the ganeti cluster master to interact with
298 @param port: the port on which the RAPI is running (default is 5080)
299 @type username: string
300 @param username: the username to connect with
301 @type password: string
302 @param password: the password to connect with
303 @type config_ssl_verification: callable
304 @param config_ssl_verification: Function to configure SSL context for
305 certificate verification
306 @type ignore_proxy: bool
307 @param ignore_proxy: Whether to ignore proxy settings
308 @param logger: Logging object
313 self._logger = logger
315 self._base_url = "https://%s:%s" % (host, port)
317 handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
319 if username is not None:
320 pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
321 pwmgr.add_password(None, self._base_url, username, password)
322 handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
324 raise Error("Specified password without username")
327 handlers.append(urllib2.ProxyHandler({}))
329 self._http = urllib2.build_opener(*handlers) # pylint: disable-msg=W0142
332 "Accept": HTTP_APP_JSON,
333 "Content-type": HTTP_APP_JSON,
334 "User-Agent": self.USER_AGENT,
338 def _EncodeQuery(query):
339 """Encode query values for RAPI URL.
341 @type query: list of two-tuples
342 @param query: Query arguments
344 @return: Query list with encoded values
349 for name, value in query:
351 result.append((name, ""))
353 elif isinstance(value, bool):
354 # Boolean values must be encoded as 0 or 1
355 result.append((name, int(value)))
357 elif isinstance(value, (list, tuple, dict)):
358 raise ValueError("Invalid query data type %r" % type(value).__name__)
361 result.append((name, value))
365 def _SendRequest(self, method, path, query, content):
366 """Sends an HTTP request.
368 This constructs a full URL, encodes and decodes HTTP bodies, and
369 handles invalid responses in a pythonic way.
372 @param method: HTTP method to use
374 @param path: HTTP URL path
375 @type query: list of two-tuples
376 @param query: query arguments to pass to urllib.urlencode
377 @type content: str or None
378 @param content: HTTP body content
381 @return: JSON-Decoded response
383 @raises CertificateError: If an invalid SSL certificate is found
384 @raises GanetiApiError: If an invalid response is returned
387 assert path.startswith("/")
390 encoded_content = self._json_encoder.encode(content)
392 encoded_content = None
395 url = [self._base_url, path]
398 url.append(urllib.urlencode(self._EncodeQuery(query)))
400 req = _RapiRequest(method, "".join(url), self._headers, encoded_content)
403 resp = self._http.open(req)
404 encoded_response_content = resp.read()
405 except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
406 raise CertificateError("SSL issue: %s" % err)
408 if encoded_response_content:
409 response_content = simplejson.loads(encoded_response_content)
411 response_content = None
413 # TODO: Are there other status codes that are valid? (redirect?)
414 if resp.code != HTTP_OK:
415 if isinstance(response_content, dict):
417 (response_content["code"],
418 response_content["message"],
419 response_content["explain"]))
421 msg = str(response_content)
423 raise GanetiApiError(msg, code=resp.code)
425 return response_content
427 def GetVersion(self):
428 """Gets the Remote API version running on the cluster.
431 @return: Ganeti Remote API version
434 return self._SendRequest(HTTP_GET, "/version", None, None)
436 def GetOperatingSystems(self):
437 """Gets the Operating Systems running in the Ganeti cluster.
440 @return: operating systems
443 return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
447 """Gets info about the cluster.
450 @return: information about the cluster
453 return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
456 def GetClusterTags(self):
457 """Gets the cluster tags.
460 @return: cluster tags
463 return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
466 def AddClusterTags(self, tags, dry_run=False):
467 """Adds tags to the cluster.
469 @type tags: list of str
470 @param tags: tags to add to the cluster
472 @param dry_run: whether to perform a dry run
478 query = [("tag", t) for t in tags]
480 query.append(("dry-run", 1))
482 return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
485 def DeleteClusterTags(self, tags, dry_run=False):
486 """Deletes tags from the cluster.
488 @type tags: list of str
489 @param tags: tags to delete
491 @param dry_run: whether to perform a dry run
494 query = [("tag", t) for t in tags]
496 query.append(("dry-run", 1))
498 return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
501 def GetInstances(self, bulk=False):
502 """Gets information about instances on the cluster.
505 @param bulk: whether to return all information about all instances
507 @rtype: list of dict or list of str
508 @return: if bulk is True, info about the instances, else a list of instances
513 query.append(("bulk", 1))
515 instances = self._SendRequest(HTTP_GET,
516 "/%s/instances" % GANETI_RAPI_VERSION,
521 return [i["id"] for i in instances]
523 def GetInstanceInfo(self, instance):
524 """Gets information about an instance.
527 @param instance: instance whose info to return
530 @return: info about the instance
533 return self._SendRequest(HTTP_GET,
534 ("/%s/instances/%s" %
535 (GANETI_RAPI_VERSION, instance)), None, None)
537 def CreateInstance(self, dry_run=False):
538 """Creates a new instance.
541 @param dry_run: whether to perform a dry run
547 # TODO: Pass arguments needed to actually create an instance.
550 query.append(("dry-run", 1))
552 return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
555 def DeleteInstance(self, instance, dry_run=False):
556 """Deletes an instance.
559 @param instance: the instance to delete
567 query.append(("dry-run", 1))
569 return self._SendRequest(HTTP_DELETE,
570 ("/%s/instances/%s" %
571 (GANETI_RAPI_VERSION, instance)), query, None)
573 def GetInstanceTags(self, instance):
574 """Gets tags for an instance.
577 @param instance: instance whose tags to return
580 @return: tags for the instance
583 return self._SendRequest(HTTP_GET,
584 ("/%s/instances/%s/tags" %
585 (GANETI_RAPI_VERSION, instance)), None, None)
587 def AddInstanceTags(self, instance, tags, dry_run=False):
588 """Adds tags to an instance.
591 @param instance: instance to add tags to
592 @type tags: list of str
593 @param tags: tags to add to the instance
595 @param dry_run: whether to perform a dry run
601 query = [("tag", t) for t in tags]
603 query.append(("dry-run", 1))
605 return self._SendRequest(HTTP_PUT,
606 ("/%s/instances/%s/tags" %
607 (GANETI_RAPI_VERSION, instance)), query, None)
609 def DeleteInstanceTags(self, instance, tags, dry_run=False):
610 """Deletes tags from an instance.
613 @param instance: instance to delete tags from
614 @type tags: list of str
615 @param tags: tags to delete
617 @param dry_run: whether to perform a dry run
620 query = [("tag", t) for t in tags]
622 query.append(("dry-run", 1))
624 return self._SendRequest(HTTP_DELETE,
625 ("/%s/instances/%s/tags" %
626 (GANETI_RAPI_VERSION, instance)), query, None)
628 def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
630 """Reboots an instance.
633 @param instance: instance to rebot
634 @type reboot_type: str
635 @param reboot_type: one of: hard, soft, full
636 @type ignore_secondaries: bool
637 @param ignore_secondaries: if True, ignores errors for the secondary node
638 while re-assembling disks (in hard-reboot mode only)
640 @param dry_run: whether to perform a dry run
645 query.append(("type", reboot_type))
646 if ignore_secondaries is not None:
647 query.append(("ignore_secondaries", ignore_secondaries))
649 query.append(("dry-run", 1))
651 return self._SendRequest(HTTP_POST,
652 ("/%s/instances/%s/reboot" %
653 (GANETI_RAPI_VERSION, instance)), query, None)
655 def ShutdownInstance(self, instance, dry_run=False):
656 """Shuts down an instance.
659 @param instance: the instance to shut down
661 @param dry_run: whether to perform a dry run
666 query.append(("dry-run", 1))
668 return self._SendRequest(HTTP_PUT,
669 ("/%s/instances/%s/shutdown" %
670 (GANETI_RAPI_VERSION, instance)), query, None)
672 def StartupInstance(self, instance, dry_run=False):
673 """Starts up an instance.
676 @param instance: the instance to start up
678 @param dry_run: whether to perform a dry run
683 query.append(("dry-run", 1))
685 return self._SendRequest(HTTP_PUT,
686 ("/%s/instances/%s/startup" %
687 (GANETI_RAPI_VERSION, instance)), query, None)
689 def ReinstallInstance(self, instance, os, no_startup=False):
690 """Reinstalls an instance.
693 @param instance: the instance to reinstall
695 @param os: the os to reinstall
696 @type no_startup: bool
697 @param no_startup: whether to start the instance automatically
702 query.append(("nostartup", 1))
703 return self._SendRequest(HTTP_POST,
704 ("/%s/instances/%s/reinstall" %
705 (GANETI_RAPI_VERSION, instance)), query, None)
707 def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
708 remote_node=None, iallocator=None, dry_run=False):
709 """Replaces disks on an instance.
712 @param instance: instance whose disks to replace
713 @type disks: list of ints
714 @param disks: Indexes of disks to replace
716 @param mode: replacement mode to use (defaults to replace_auto)
717 @type remote_node: str or None
718 @param remote_node: new secondary node to use (for use with
719 replace_new_secondary mode)
720 @type iallocator: str or None
721 @param iallocator: instance allocator plugin to use (for use with
724 @param dry_run: whether to perform a dry run
735 query.append(("disks", ",".join(str(idx) for idx in disks)))
738 query.append(("remote_node", remote_node))
741 query.append(("iallocator", iallocator))
744 query.append(("dry-run", 1))
746 return self._SendRequest(HTTP_POST,
747 ("/%s/instances/%s/replace-disks" %
748 (GANETI_RAPI_VERSION, instance)), query, None)
751 """Gets all jobs for the cluster.
754 @return: job ids for the cluster
758 for j in self._SendRequest(HTTP_GET,
759 "/%s/jobs" % GANETI_RAPI_VERSION,
762 def GetJobStatus(self, job_id):
763 """Gets the status of a job.
766 @param job_id: job id whose status to query
772 return self._SendRequest(HTTP_GET,
773 "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
776 def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
777 """Waits for job changes.
780 @param job_id: Job ID for which to wait
785 "previous_job_info": prev_job_info,
786 "previous_log_serial": prev_log_serial,
789 return self._SendRequest(HTTP_GET,
790 "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
793 def CancelJob(self, job_id, dry_run=False):
797 @param job_id: id of the job to delete
799 @param dry_run: whether to perform a dry run
804 query.append(("dry-run", 1))
806 return self._SendRequest(HTTP_DELETE,
807 "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
810 def GetNodes(self, bulk=False):
811 """Gets all nodes in the cluster.
814 @param bulk: whether to return all information about all instances
816 @rtype: list of dict or str
817 @return: if bulk is true, info about nodes in the cluster,
818 else list of nodes in the cluster
823 query.append(("bulk", 1))
825 nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
830 return [n["id"] for n in nodes]
832 def GetNodeInfo(self, node):
833 """Gets information about a node.
836 @param node: node whose info to return
839 @return: info about the node
842 return self._SendRequest(HTTP_GET,
843 "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
846 def EvacuateNode(self, node, iallocator=None, remote_node=None,
848 """Evacuates instances from a Ganeti node.
851 @param node: node to evacuate
852 @type iallocator: str or None
853 @param iallocator: instance allocator to use
854 @type remote_node: str
855 @param remote_node: node to evaucate to
857 @param dry_run: whether to perform a dry run
862 @raises GanetiApiError: if an iallocator and remote_node are both specified
865 if iallocator and remote_node:
866 raise GanetiApiError("Only one of iallocator or remote_node can be used")
870 query.append(("iallocator", iallocator))
872 query.append(("remote_node", remote_node))
874 query.append(("dry-run", 1))
876 return self._SendRequest(HTTP_POST,
877 ("/%s/nodes/%s/evacuate" %
878 (GANETI_RAPI_VERSION, node)), query, None)
880 def MigrateNode(self, node, live=True, dry_run=False):
881 """Migrates all primary instances from a node.
884 @param node: node to migrate
886 @param live: whether to use live migration
888 @param dry_run: whether to perform a dry run
896 query.append(("live", 1))
898 query.append(("dry-run", 1))
900 return self._SendRequest(HTTP_POST,
901 ("/%s/nodes/%s/migrate" %
902 (GANETI_RAPI_VERSION, node)), query, None)
904 def GetNodeRole(self, node):
905 """Gets the current role for a node.
908 @param node: node whose role to return
911 @return: the current role for a node
914 return self._SendRequest(HTTP_GET,
915 ("/%s/nodes/%s/role" %
916 (GANETI_RAPI_VERSION, node)), None, None)
918 def SetNodeRole(self, node, role, force=False):
919 """Sets the role for a node.
922 @param node: the node whose role to set
924 @param role: the role to set for the node
926 @param force: whether to force the role change
936 return self._SendRequest(HTTP_PUT,
937 ("/%s/nodes/%s/role" %
938 (GANETI_RAPI_VERSION, node)), query, role)
940 def GetNodeStorageUnits(self, node, storage_type, output_fields):
941 """Gets the storage units for a node.
944 @param node: the node whose storage units to return
945 @type storage_type: str
946 @param storage_type: storage type whose units to return
947 @type output_fields: str
948 @param output_fields: storage type fields to return
951 @return: job id where results can be retrieved
955 ("storage_type", storage_type),
956 ("output_fields", output_fields),
959 return self._SendRequest(HTTP_GET,
960 ("/%s/nodes/%s/storage" %
961 (GANETI_RAPI_VERSION, node)), query, None)
963 def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
964 """Modifies parameters of storage units on the node.
967 @param node: node whose storage units to modify
968 @type storage_type: str
969 @param storage_type: storage type whose units to modify
971 @param name: name of the storage unit
972 @type allocatable: bool or None
973 @param allocatable: Whether to set the "allocatable" flag on the storage
974 unit (None=no modification, True=set, False=unset)
981 ("storage_type", storage_type),
985 if allocatable is not None:
986 query.append(("allocatable", allocatable))
988 return self._SendRequest(HTTP_PUT,
989 ("/%s/nodes/%s/storage/modify" %
990 (GANETI_RAPI_VERSION, node)), query, None)
992 def RepairNodeStorageUnits(self, node, storage_type, name):
993 """Repairs a storage unit on the node.
996 @param node: node whose storage units to repair
997 @type storage_type: str
998 @param storage_type: storage type to repair
1000 @param name: name of the storage unit to repair
1007 ("storage_type", storage_type),
1011 return self._SendRequest(HTTP_PUT,
1012 ("/%s/nodes/%s/storage/repair" %
1013 (GANETI_RAPI_VERSION, node)), query, None)
1015 def GetNodeTags(self, node):
1016 """Gets the tags for a node.
1019 @param node: node whose tags to return
1022 @return: tags for the node
1025 return self._SendRequest(HTTP_GET,
1026 ("/%s/nodes/%s/tags" %
1027 (GANETI_RAPI_VERSION, node)), None, None)
1029 def AddNodeTags(self, node, tags, dry_run=False):
1030 """Adds tags to a node.
1033 @param node: node to add tags to
1034 @type tags: list of str
1035 @param tags: tags to add to the node
1037 @param dry_run: whether to perform a dry run
1043 query = [("tag", t) for t in tags]
1045 query.append(("dry-run", 1))
1047 return self._SendRequest(HTTP_PUT,
1048 ("/%s/nodes/%s/tags" %
1049 (GANETI_RAPI_VERSION, node)), query, tags)
1051 def DeleteNodeTags(self, node, tags, dry_run=False):
1052 """Delete tags from a node.
1055 @param node: node to remove tags from
1056 @type tags: list of str
1057 @param tags: tags to remove from the node
1059 @param dry_run: whether to perform a dry run
1065 query = [("tag", t) for t in tags]
1067 query.append(("dry-run", 1))
1069 return self._SendRequest(HTTP_DELETE,
1070 ("/%s/nodes/%s/tags" %
1071 (GANETI_RAPI_VERSION, node)), query, None)