client.py 39.3 KB
Newer Older
David Knowles's avatar
David Knowles committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#
#

# Copyright (C) 2010 Google Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.


22
23
24
25
26
27
28
29
30
31
"""Ganeti RAPI client.

@attention: To use the RAPI client, the application B{must} call
            C{pycurl.global_init} during initialization and
            C{pycurl.global_cleanup} before exiting the process. This is very
            important in multi-threaded programs. See curl_global_init(3) and
            curl_global_cleanup(3) for details. The decorator L{UsesRapiClient}
            can be used.

"""
David Knowles's avatar
David Knowles committed
32

33
34
35
# No Ganeti-specific modules should be imported. The RAPI client is supposed to
# be standalone.

36
import logging
David Knowles's avatar
David Knowles committed
37
38
import simplejson
import urllib
39
40
41
42
43
44
45
import threading
import pycurl

try:
  from cStringIO import StringIO
except ImportError:
  from StringIO import StringIO
David Knowles's avatar
David Knowles committed
46
47


48
GANETI_RAPI_PORT = 5080
49
GANETI_RAPI_VERSION = 2
50

David Knowles's avatar
David Knowles committed
51
52
53
54
HTTP_DELETE = "DELETE"
HTTP_GET = "GET"
HTTP_PUT = "PUT"
HTTP_POST = "POST"
55
HTTP_OK = 200
56
HTTP_NOT_FOUND = 404
57
58
HTTP_APP_JSON = "application/json"

David Knowles's avatar
David Knowles committed
59
60
61
62
REPLACE_DISK_PRI = "replace_on_primary"
REPLACE_DISK_SECONDARY = "replace_on_secondary"
REPLACE_DISK_CHG = "replace_new_secondary"
REPLACE_DISK_AUTO = "replace_auto"
63
64
65
66
67
68

NODE_ROLE_DRAINED = "drained"
NODE_ROLE_MASTER_CANDIATE = "master-candidate"
NODE_ROLE_MASTER = "master"
NODE_ROLE_OFFLINE = "offline"
NODE_ROLE_REGULAR = "regular"
David Knowles's avatar
David Knowles committed
69

70
71
72
# Internal constants
_REQ_DATA_VERSION_FIELD = "__version__"
_INST_CREATE_REQV1 = "instance-create-reqv1"
73
74
75
76
77
78
79
_INST_NIC_PARAMS = frozenset(["mac", "ip", "mode", "link", "bridge"])
_INST_CREATE_V0_DISK_PARAMS = frozenset(["size"])
_INST_CREATE_V0_PARAMS = frozenset([
  "os", "pnode", "snode", "iallocator", "start", "ip_check", "name_check",
  "hypervisor", "file_storage_dir", "file_driver", "dry_run",
  ])
_INST_CREATE_V0_DPARAMS = frozenset(["beparams", "hvparams"])
80

81
82
83
84
85
86
87
88
89
90
91
92
93
# Older pycURL versions don't have all error constants
try:
  _CURLE_SSL_CACERT = pycurl.E_SSL_CACERT
  _CURLE_SSL_CACERT_BADFILE = pycurl.E_SSL_CACERT_BADFILE
except AttributeError:
  _CURLE_SSL_CACERT = 60
  _CURLE_SSL_CACERT_BADFILE = 77

_CURL_SSL_CERT_ERRORS = frozenset([
  _CURLE_SSL_CACERT,
  _CURLE_SSL_CACERT_BADFILE,
  ])

David Knowles's avatar
David Knowles committed
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112

class Error(Exception):
  """Base error class for this module.

  """
  pass


class CertificateError(Error):
  """Raised when a problem is found with the SSL certificate.

  """
  pass


class GanetiApiError(Error):
  """Generic error raised from Ganeti API.

  """
113
114
115
  def __init__(self, msg, code=None):
    Error.__init__(self, msg)
    self.code = code
David Knowles's avatar
David Knowles committed
116
117


118
119
def UsesRapiClient(fn):
  """Decorator for code using RAPI client to initialize pycURL.
120
121

  """
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
  def wrapper(*args, **kwargs):
    # curl_global_init(3) and curl_global_cleanup(3) must be called with only
    # one thread running. This check is just a safety measure -- it doesn't
    # cover all cases.
    assert threading.activeCount() == 1, \
           "Found active threads when initializing pycURL"

    pycurl.global_init(pycurl.GLOBAL_ALL)
    try:
      return fn(*args, **kwargs)
    finally:
      pycurl.global_cleanup()

  return wrapper


def GenericCurlConfig(verbose=False, use_signal=False,
                      use_curl_cabundle=False, cafile=None, capath=None,
                      proxy=None, verify_hostname=False,
                      connect_timeout=None, timeout=None,
                      _pycurl_version_fn=pycurl.version_info):
  """Curl configuration function generator.

  @type verbose: bool
  @param verbose: Whether to set cURL to verbose mode
  @type use_signal: bool
  @param use_signal: Whether to allow cURL to use signals
  @type use_curl_cabundle: bool
  @param use_curl_cabundle: Whether to use cURL's default CA bundle
  @type cafile: string
  @param cafile: In which file we can find the certificates
  @type capath: string
  @param capath: In which directory we can find the certificates
  @type proxy: string
  @param proxy: Proxy to use, None for default behaviour and empty string for
                disabling proxies (see curl_easy_setopt(3))
  @type verify_hostname: bool
  @param verify_hostname: Whether to verify the remote peer certificate's
                          commonName
  @type connect_timeout: number
  @param connect_timeout: Timeout for establishing connection in seconds
  @type timeout: number
  @param timeout: Timeout for complete transfer in seconds (see
                  curl_easy_setopt(3)).
166
167

  """
168
169
  if use_curl_cabundle and (cafile or capath):
    raise Error("Can not use default CA bundle when CA file or path is set")
170

171
172
  def _ConfigCurl(curl, logger):
    """Configures a cURL object
173

174
175
    @type curl: pycurl.Curl
    @param curl: cURL object
176
177

    """
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
    logger.debug("Using cURL version %s", pycurl.version)

    # pycurl.version_info returns a tuple with information about the used
    # version of libcurl. Item 5 is the SSL library linked to it.
    # e.g.: (3, '7.18.0', 463360, 'x86_64-pc-linux-gnu', 1581, 'GnuTLS/2.0.4',
    # 0, '1.2.3.3', ...)
    sslver = _pycurl_version_fn()[5]
    if not sslver:
      raise Error("No SSL support in cURL")

    lcsslver = sslver.lower()
    if lcsslver.startswith("openssl/"):
      pass
    elif lcsslver.startswith("gnutls/"):
      if capath:
        raise Error("cURL linked against GnuTLS has no support for a"
                    " CA path (%s)" % (pycurl.version, ))
195
    else:
196
197
198
199
200
201
202
203
204
205
206
207
208
209
      raise NotImplementedError("cURL uses unsupported SSL version '%s'" %
                                sslver)

    curl.setopt(pycurl.VERBOSE, verbose)
    curl.setopt(pycurl.NOSIGNAL, not use_signal)

    # Whether to verify remote peer's CN
    if verify_hostname:
      # curl_easy_setopt(3): "When CURLOPT_SSL_VERIFYHOST is 2, that
      # certificate must indicate that the server is the server to which you
      # meant to connect, or the connection fails. [...] When the value is 1,
      # the certificate must contain a Common Name field, but it doesn't matter
      # what name it says. [...]"
      curl.setopt(pycurl.SSL_VERIFYHOST, 2)
210
    else:
211
212
213
214
215
216
217
218
219
220
221
222
223
      curl.setopt(pycurl.SSL_VERIFYHOST, 0)

    if cafile or capath or use_curl_cabundle:
      # Require certificates to be checked
      curl.setopt(pycurl.SSL_VERIFYPEER, True)
      if cafile:
        curl.setopt(pycurl.CAINFO, str(cafile))
      if capath:
        curl.setopt(pycurl.CAPATH, str(capath))
      # Not changing anything for using default CA bundle
    else:
      # Disable SSL certificate verification
      curl.setopt(pycurl.SSL_VERIFYPEER, False)
224

225
226
    if proxy is not None:
      curl.setopt(pycurl.PROXY, str(proxy))
227

228
229
230
231
232
    # Timeouts
    if connect_timeout is not None:
      curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
    if timeout is not None:
      curl.setopt(pycurl.TIMEOUT, timeout)
233

234
  return _ConfigCurl
235
236


David Knowles's avatar
David Knowles committed
237
238
239
240
241
class GanetiRapiClient(object):
  """Ganeti RAPI client.

  """
  USER_AGENT = "Ganeti RAPI Client"
242
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
David Knowles's avatar
David Knowles committed
243

244
  def __init__(self, host, port=GANETI_RAPI_PORT,
245
               username=None, password=None, logger=logging,
246
               curl_config_fn=None, curl_factory=None):
247
    """Initializes this class.
David Knowles's avatar
David Knowles committed
248

249
250
    @type host: string
    @param host: the ganeti cluster master to interact with
David Knowles's avatar
David Knowles committed
251
    @type port: int
252
253
    @param port: the port on which the RAPI is running (default is 5080)
    @type username: string
David Knowles's avatar
David Knowles committed
254
    @param username: the username to connect with
255
    @type password: string
David Knowles's avatar
David Knowles committed
256
    @param password: the password to connect with
257
258
    @type curl_config_fn: callable
    @param curl_config_fn: Function to configure C{pycurl.Curl} object
259
    @param logger: Logging object
David Knowles's avatar
David Knowles committed
260
261

    """
262
263
    self._username = username
    self._password = password
264
    self._logger = logger
265
266
    self._curl_config_fn = curl_config_fn
    self._curl_factory = curl_factory
David Knowles's avatar
David Knowles committed
267

268
    self._base_url = "https://%s:%s" % (host, port)
David Knowles's avatar
David Knowles committed
269

270
271
272
273
274
275
276
277
278
279
280
281
282
283
    if username is not None:
      if password is None:
        raise Error("Password not specified")
    elif password:
      raise Error("Specified password without username")

  def _CreateCurl(self):
    """Creates a cURL object.

    """
    # Create pycURL object if no factory is provided
    if self._curl_factory:
      curl = self._curl_factory()
    else:
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
      curl = pycurl.Curl()

    # Default cURL settings
    curl.setopt(pycurl.VERBOSE, False)
    curl.setopt(pycurl.FOLLOWLOCATION, False)
    curl.setopt(pycurl.MAXREDIRS, 5)
    curl.setopt(pycurl.NOSIGNAL, True)
    curl.setopt(pycurl.USERAGENT, self.USER_AGENT)
    curl.setopt(pycurl.SSL_VERIFYHOST, 0)
    curl.setopt(pycurl.SSL_VERIFYPEER, False)
    curl.setopt(pycurl.HTTPHEADER, [
      "Accept: %s" % HTTP_APP_JSON,
      "Content-type: %s" % HTTP_APP_JSON,
      ])

299
300
301
302
303
    assert ((self._username is None and self._password is None) ^
            (self._username is not None and self._password is not None))

    if self._username:
      # Setup authentication
304
      curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
305
306
      curl.setopt(pycurl.USERPWD,
                  str("%s:%s" % (self._username, self._password)))
307

308
    # Call external configuration function
309
310
    if self._curl_config_fn:
      self._curl_config_fn(curl, self._logger)
David Knowles's avatar
David Knowles committed
311

312
    return curl
David Knowles's avatar
David Knowles committed
313

314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
  @staticmethod
  def _EncodeQuery(query):
    """Encode query values for RAPI URL.

    @type query: list of two-tuples
    @param query: Query arguments
    @rtype: list
    @return: Query list with encoded values

    """
    result = []

    for name, value in query:
      if value is None:
        result.append((name, ""))

      elif isinstance(value, bool):
        # Boolean values must be encoded as 0 or 1
        result.append((name, int(value)))

      elif isinstance(value, (list, tuple, dict)):
        raise ValueError("Invalid query data type %r" % type(value).__name__)

      else:
        result.append((name, value))

    return result

342
  def _SendRequest(self, method, path, query, content):
David Knowles's avatar
David Knowles committed
343
344
345
346
347
    """Sends an HTTP request.

    This constructs a full URL, encodes and decodes HTTP bodies, and
    handles invalid responses in a pythonic way.

348
    @type method: string
David Knowles's avatar
David Knowles committed
349
    @param method: HTTP method to use
350
    @type path: string
David Knowles's avatar
David Knowles committed
351
352
353
354
355
356
357
358
359
    @param path: HTTP URL path
    @type query: list of two-tuples
    @param query: query arguments to pass to urllib.urlencode
    @type content: str or None
    @param content: HTTP body content

    @rtype: str
    @return: JSON-Decoded response

David Knowles's avatar
David Knowles committed
360
    @raises CertificateError: If an invalid SSL certificate is found
David Knowles's avatar
David Knowles committed
361
362
363
    @raises GanetiApiError: If an invalid response is returned

    """
364
365
    assert path.startswith("/")

366
    curl = self._CreateCurl()
367

368
    if content is not None:
369
370
      encoded_content = self._json_encoder.encode(content)
    else:
371
      encoded_content = ""
David Knowles's avatar
David Knowles committed
372

373
    # Build URL
374
    urlparts = [self._base_url, path]
375
    if query:
376
377
      urlparts.append("?")
      urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
378

379
380
    url = "".join(urlparts)

381
382
    self._logger.debug("Sending request %s %s (content=%r)",
                       method, url, encoded_content)
383
384
385

    # Buffer for response
    encoded_resp_body = StringIO()
386

387
388
389
390
391
    # Configure cURL
    curl.setopt(pycurl.CUSTOMREQUEST, str(method))
    curl.setopt(pycurl.URL, str(url))
    curl.setopt(pycurl.POSTFIELDS, str(encoded_content))
    curl.setopt(pycurl.WRITEFUNCTION, encoded_resp_body.write)
392

David Knowles's avatar
David Knowles committed
393
    try:
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
      # Send request and wait for response
      try:
        curl.perform()
      except pycurl.error, err:
        if err.args[0] in _CURL_SSL_CERT_ERRORS:
          raise CertificateError("SSL certificate error %s" % err)

        raise GanetiApiError(str(err))
    finally:
      # Reset settings to not keep references to large objects in memory
      # between requests
      curl.setopt(pycurl.POSTFIELDS, "")
      curl.setopt(pycurl.WRITEFUNCTION, lambda _: None)

    # Get HTTP response code
    http_code = curl.getinfo(pycurl.RESPONSE_CODE)

    # Was anything written to the response buffer?
    if encoded_resp_body.tell():
      response_content = simplejson.loads(encoded_resp_body.getvalue())
414
415
    else:
      response_content = None
David Knowles's avatar
David Knowles committed
416

417
    if http_code != HTTP_OK:
418
      if isinstance(response_content, dict):
David Knowles's avatar
David Knowles committed
419
        msg = ("%s %s: %s" %
420
421
422
               (response_content["code"],
                response_content["message"],
                response_content["explain"]))
David Knowles's avatar
David Knowles committed
423
      else:
424
425
        msg = str(response_content)

426
      raise GanetiApiError(msg, code=http_code)
David Knowles's avatar
David Knowles committed
427

428
    return response_content
David Knowles's avatar
David Knowles committed
429
430

  def GetVersion(self):
431
    """Gets the Remote API version running on the cluster.
David Knowles's avatar
David Knowles committed
432
433

    @rtype: int
David Knowles's avatar
David Knowles committed
434
    @return: Ganeti Remote API version
David Knowles's avatar
David Knowles committed
435
436

    """
437
    return self._SendRequest(HTTP_GET, "/version", None, None)
David Knowles's avatar
David Knowles committed
438

439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
  def GetFeatures(self):
    """Gets the list of optional features supported by RAPI server.

    @rtype: list
    @return: List of optional features

    """
    try:
      return self._SendRequest(HTTP_GET, "/%s/features" % GANETI_RAPI_VERSION,
                               None, None)
    except GanetiApiError, err:
      # Older RAPI servers don't support this resource
      if err.code == HTTP_NOT_FOUND:
        return []

      raise

David Knowles's avatar
David Knowles committed
456
457
458
459
460
461
462
  def GetOperatingSystems(self):
    """Gets the Operating Systems running in the Ganeti cluster.

    @rtype: list of str
    @return: operating systems

    """
463
464
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
465
466
467
468
469
470
471
472

  def GetInfo(self):
    """Gets info about the cluster.

    @rtype: dict
    @return: information about the cluster

    """
473
474
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
475
476
477
478
479
480
481
482

  def GetClusterTags(self):
    """Gets the cluster tags.

    @rtype: list of str
    @return: cluster tags

    """
483
484
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
                             None, None)
David Knowles's avatar
David Knowles committed
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501

  def AddClusterTags(self, tags, dry_run=False):
    """Adds tags to the cluster.

    @type tags: list of str
    @param tags: tags to add to the cluster
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

502
503
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
504
505
506
507
508
509
510
511
512
513
514
515
516
517

  def DeleteClusterTags(self, tags, dry_run=False):
    """Deletes tags from the cluster.

    @type tags: list of str
    @param tags: tags to delete
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

518
519
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534

  def GetInstances(self, bulk=False):
    """Gets information about instances on the cluster.

    @type bulk: bool
    @param bulk: whether to return all information about all instances

    @rtype: list of dict or list of str
    @return: if bulk is True, info about the instances, else a list of instances

    """
    query = []
    if bulk:
      query.append(("bulk", 1))

535
536
537
    instances = self._SendRequest(HTTP_GET,
                                  "/%s/instances" % GANETI_RAPI_VERSION,
                                  query, None)
David Knowles's avatar
David Knowles committed
538
539
540
541
542
    if bulk:
      return instances
    else:
      return [i["id"] for i in instances]

543
  def GetInstance(self, instance):
David Knowles's avatar
David Knowles committed
544
545
546
547
548
549
550
551
552
    """Gets information about an instance.

    @type instance: str
    @param instance: instance whose info to return

    @rtype: dict
    @return: info about the instance

    """
553
554
555
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s" %
                              (GANETI_RAPI_VERSION, instance)), None, None)
David Knowles's avatar
David Knowles committed
556

557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
  def GetInstanceInfo(self, instance, static=None):
    """Gets information about an instance.

    @type instance: string
    @param instance: Instance name
    @rtype: string
    @return: Job ID

    """
    if static is not None:
      query = [("static", static)]
    else:
      query = None

    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/info" %
                              (GANETI_RAPI_VERSION, instance)), query, None)

575
576
  def CreateInstance(self, mode, name, disk_template, disks, nics,
                     **kwargs):
David Knowles's avatar
David Knowles committed
577
578
    """Creates a new instance.

579
580
581
582
583
584
585
586
587
588
589
590
591
    More details for parameters can be found in the RAPI documentation.

    @type mode: string
    @param mode: Instance creation mode
    @type name: string
    @param name: Hostname of the instance to create
    @type disk_template: string
    @param disk_template: Disk template for instance (e.g. plain, diskless,
                          file, or drbd)
    @type disks: list of dicts
    @param disks: List of disk definitions
    @type nics: list of dicts
    @param nics: List of NIC definitions
David Knowles's avatar
David Knowles committed
592
    @type dry_run: bool
593
    @keyword dry_run: whether to perform a dry run
David Knowles's avatar
David Knowles committed
594
595
596
597
598
599

    @rtype: int
    @return: job id

    """
    query = []
600
601

    if kwargs.get("dry_run"):
David Knowles's avatar
David Knowles committed
602
603
      query.append(("dry-run", 1))

604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
    if _INST_CREATE_REQV1 in self.GetFeatures():
      # All required fields for request data version 1
      body = {
        _REQ_DATA_VERSION_FIELD: 1,
        "mode": mode,
        "name": name,
        "disk_template": disk_template,
        "disks": disks,
        "nics": nics,
        }

      conflicts = set(kwargs.iterkeys()) & set(body.iterkeys())
      if conflicts:
        raise GanetiApiError("Required fields can not be specified as"
                             " keywords: %s" % ", ".join(conflicts))

      body.update((key, value) for key, value in kwargs.iteritems()
                  if key != "dry_run")
    else:
623
624
625
626
627
      # Old request format (version 0)

      # The following code must make sure that an exception is raised when an
      # unsupported setting is requested by the caller. Otherwise this can lead
      # to bugs difficult to find. The interface of this function must stay
628
629
      # exactly the same for version 0 and 1 (e.g. they aren't allowed to
      # require different data types).
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698

      # Validate disks
      for idx, disk in enumerate(disks):
        unsupported = set(disk.keys()) - _INST_CREATE_V0_DISK_PARAMS
        if unsupported:
          raise GanetiApiError("Server supports request version 0 only, but"
                               " disk %s specifies the unsupported parameters"
                               " %s, allowed are %s" %
                               (idx, unsupported,
                                list(_INST_CREATE_V0_DISK_PARAMS)))

      assert (len(_INST_CREATE_V0_DISK_PARAMS) == 1 and
              "size" in _INST_CREATE_V0_DISK_PARAMS)
      disk_sizes = [disk["size"] for disk in disks]

      # Validate NICs
      if not nics:
        raise GanetiApiError("Server supports request version 0 only, but"
                             " no NIC specified")
      elif len(nics) > 1:
        raise GanetiApiError("Server supports request version 0 only, but"
                             " more than one NIC specified")

      assert len(nics) == 1

      unsupported = set(nics[0].keys()) - _INST_NIC_PARAMS
      if unsupported:
        raise GanetiApiError("Server supports request version 0 only, but"
                             " NIC 0 specifies the unsupported parameters %s,"
                             " allowed are %s" %
                             (unsupported, list(_INST_NIC_PARAMS)))

      # Validate other parameters
      unsupported = (set(kwargs.keys()) - _INST_CREATE_V0_PARAMS -
                     _INST_CREATE_V0_DPARAMS)
      if unsupported:
        allowed = _INST_CREATE_V0_PARAMS.union(_INST_CREATE_V0_DPARAMS)
        raise GanetiApiError("Server supports request version 0 only, but"
                             " the following unsupported parameters are"
                             " specified: %s, allowed are %s" %
                             (unsupported, list(allowed)))

      # All required fields for request data version 0
      body = {
        _REQ_DATA_VERSION_FIELD: 0,
        "name": name,
        "disk_template": disk_template,
        "disks": disk_sizes,
        }

      # NIC fields
      assert len(nics) == 1
      assert not (set(body.keys()) & set(nics[0].keys()))
      body.update(nics[0])

      # Copy supported fields
      assert not (set(body.keys()) & set(kwargs.keys()))
      body.update(dict((key, value) for key, value in kwargs.items()
                       if key in _INST_CREATE_V0_PARAMS))

      # Merge dictionaries
      for i in (value for key, value in kwargs.items()
                if key in _INST_CREATE_V0_DPARAMS):
        assert not (set(body.keys()) & set(i.keys()))
        body.update(i)

      assert not (set(kwargs.keys()) -
                  (_INST_CREATE_V0_PARAMS | _INST_CREATE_V0_DPARAMS))
      assert not (set(body.keys()) & _INST_CREATE_V0_DPARAMS)
699

700
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
701
                             query, body)
David Knowles's avatar
David Knowles committed
702
703
704
705
706
707
708

  def DeleteInstance(self, instance, dry_run=False):
    """Deletes an instance.

    @type instance: str
    @param instance: the instance to delete

709
710
711
    @rtype: int
    @return: job id

David Knowles's avatar
David Knowles committed
712
713
714
715
716
    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

717
718
719
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
720

721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
  def ModifyInstance(self, instance, **kwargs):
    """Modifies an instance.

    More details for parameters can be found in the RAPI documentation.

    @type instance: string
    @param instance: Instance name
    @rtype: int
    @return: job id

    """
    body = kwargs

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/modify" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

David Knowles's avatar
David Knowles committed
738
739
740
741
742
743
744
745
746
747
  def GetInstanceTags(self, instance):
    """Gets tags for an instance.

    @type instance: str
    @param instance: instance whose tags to return

    @rtype: list of str
    @return: tags for the instance

    """
748
749
750
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), None, None)
David Knowles's avatar
David Knowles committed
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769

  def AddInstanceTags(self, instance, tags, dry_run=False):
    """Adds tags to an instance.

    @type instance: str
    @param instance: instance to add tags to
    @type tags: list of str
    @param tags: tags to add to the instance
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

770
771
772
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788

  def DeleteInstanceTags(self, instance, tags, dry_run=False):
    """Deletes tags from an instance.

    @type instance: str
    @param instance: instance to delete tags from
    @type tags: list of str
    @param tags: tags to delete
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

789
790
791
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815

  def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
                     dry_run=False):
    """Reboots an instance.

    @type instance: str
    @param instance: instance to rebot
    @type reboot_type: str
    @param reboot_type: one of: hard, soft, full
    @type ignore_secondaries: bool
    @param ignore_secondaries: if True, ignores errors for the secondary node
        while re-assembling disks (in hard-reboot mode only)
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if reboot_type:
      query.append(("type", reboot_type))
    if ignore_secondaries is not None:
      query.append(("ignore_secondaries", ignore_secondaries))
    if dry_run:
      query.append(("dry-run", 1))

816
817
818
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reboot" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
819
820
821
822
823
824
825
826
827
828
829
830
831
832

  def ShutdownInstance(self, instance, dry_run=False):
    """Shuts down an instance.

    @type instance: str
    @param instance: the instance to shut down
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

833
834
835
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/shutdown" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
836
837
838
839
840
841
842
843
844
845
846
847
848
849

  def StartupInstance(self, instance, dry_run=False):
    """Starts up an instance.

    @type instance: str
    @param instance: the instance to start up
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

850
851
852
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/startup" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
853

854
  def ReinstallInstance(self, instance, os=None, no_startup=False):
David Knowles's avatar
David Knowles committed
855
856
857
    """Reinstalls an instance.

    @type instance: str
858
859
860
861
    @param instance: The instance to reinstall
    @type os: str or None
    @param os: The operating system to reinstall. If None, the instance's
        current operating system will be installed again
David Knowles's avatar
David Knowles committed
862
    @type no_startup: bool
863
    @param no_startup: Whether to start the instance automatically
David Knowles's avatar
David Knowles committed
864
865

    """
866
867
868
    query = []
    if os:
      query.append(("os", os))
David Knowles's avatar
David Knowles committed
869
870
    if no_startup:
      query.append(("nostartup", 1))
871
872
873
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reinstall" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
874

875
  def ReplaceInstanceDisks(self, instance, disks=None, mode=REPLACE_DISK_AUTO,
876
                           remote_node=None, iallocator=None, dry_run=False):
David Knowles's avatar
David Knowles committed
877
878
879
880
    """Replaces disks on an instance.

    @type instance: str
    @param instance: instance whose disks to replace
881
882
    @type disks: list of ints
    @param disks: Indexes of disks to replace
David Knowles's avatar
David Knowles committed
883
    @type mode: str
884
    @param mode: replacement mode to use (defaults to replace_auto)
David Knowles's avatar
David Knowles committed
885
886
    @type remote_node: str or None
    @param remote_node: new secondary node to use (for use with
887
        replace_new_secondary mode)
David Knowles's avatar
David Knowles committed
888
889
    @type iallocator: str or None
    @param iallocator: instance allocator plugin to use (for use with
890
                       replace_auto mode)
David Knowles's avatar
David Knowles committed
891
892
893
894
895
896
897
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
898
899
900
    query = [
      ("mode", mode),
      ]
David Knowles's avatar
David Knowles committed
901

902
903
904
905
    if disks:
      query.append(("disks", ",".join(str(idx) for idx in disks)))

    if remote_node:
David Knowles's avatar
David Knowles committed
906
907
      query.append(("remote_node", remote_node))

908
909
910
    if iallocator:
      query.append(("iallocator", iallocator))

David Knowles's avatar
David Knowles committed
911
912
913
914
    if dry_run:
      query.append(("dry-run", 1))

    return self._SendRequest(HTTP_POST,
915
916
                             ("/%s/instances/%s/replace-disks" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
917

918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
  def PrepareExport(self, instance, mode):
    """Prepares an instance for an export.

    @type instance: string
    @param instance: Instance name
    @type mode: string
    @param mode: Export mode
    @rtype: string
    @return: Job ID

    """
    query = [("mode", mode)]
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/prepare-export" %
                              (GANETI_RAPI_VERSION, instance)), query, None)

  def ExportInstance(self, instance, mode, destination, shutdown=None,
                     remove_instance=None,
                     x509_key_name=None, destination_x509_ca=None):
    """Exports an instance.

    @type instance: string
    @param instance: Instance name
    @type mode: string
    @param mode: Export mode
    @rtype: string
    @return: Job ID

    """
    body = {
      "destination": destination,
      "mode": mode,
      }

    if shutdown is not None:
      body["shutdown"] = shutdown

    if remove_instance is not None:
      body["remove_instance"] = remove_instance

    if x509_key_name is not None:
      body["x509_key_name"] = x509_key_name

    if destination_x509_ca is not None:
      body["destination_x509_ca"] = destination_x509_ca

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/export" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

968
  def MigrateInstance(self, instance, mode=None, cleanup=None):
969
    """Migrates an instance.
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990

    @type instance: string
    @param instance: Instance name
    @type mode: string
    @param mode: Migration mode
    @type cleanup: bool
    @param cleanup: Whether to clean up a previously failed migration

    """
    body = {}

    if mode is not None:
      body["mode"] = mode

    if cleanup is not None:
      body["cleanup"] = cleanup

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/migrate" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
  def RenameInstance(self, instance, new_name, ip_check=None, name_check=None):
    """Changes the name of an instance.

    @type instance: string
    @param instance: Instance name
    @type new_name: string
    @param new_name: New instance name
    @type ip_check: bool
    @param ip_check: Whether to ensure instance's IP address is inactive
    @type name_check: bool
    @param name_check: Whether to ensure instance's name is resolvable

    """
    body = {
      "new_name": new_name,
      }

    if ip_check is not None:
      body["ip_check"] = ip_check

    if name_check is not None:
      body["name_check"] = name_check

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/rename" %
                              (GANETI_RAPI_VERSION, instance)), None, body)

David Knowles's avatar
David Knowles committed
1018
1019
1020
1021
1022
1023
1024
  def GetJobs(self):
    """Gets all jobs for the cluster.

    @rtype: list of int
    @return: job ids for the cluster

    """
1025
    return [int(j["id"])
1026
1027
1028
            for j in self._SendRequest(HTTP_GET,
                                       "/%s/jobs" % GANETI_RAPI_VERSION,
                                       None, None)]
David Knowles's avatar
David Knowles committed
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039

  def GetJobStatus(self, job_id):
    """Gets the status of a job.

    @type job_id: int
    @param job_id: job id whose status to query

    @rtype: dict
    @return: job status

    """
1040
1041
1042
    return self._SendRequest(HTTP_GET,
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
                             None, None)
David Knowles's avatar
David Knowles committed
1043

1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
  def WaitForJobChange(self, job_id, fields, prev_job_info, prev_log_serial):
    """Waits for job changes.

    @type job_id: int
    @param job_id: Job ID for which to wait

    """
    body = {
      "fields": fields,
      "previous_job_info": prev_job_info,
      "previous_log_serial": prev_log_serial,
      }

1057
1058
1059
    return self._SendRequest(HTTP_GET,
                             "/%s/jobs/%s/wait" % (GANETI_RAPI_VERSION, job_id),
                             None, body)
1060

1061
1062
  def CancelJob(self, job_id, dry_run=False):
    """Cancels a job.
David Knowles's avatar
David Knowles committed
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073

    @type job_id: int
    @param job_id: id of the job to delete
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    """
    query = []
    if dry_run:
      query.append(("dry-run", 1))

1074
1075
1076
    return self._SendRequest(HTTP_DELETE,
                             "/%s/jobs/%s" % (GANETI_RAPI_VERSION, job_id),
                             query, None)
David Knowles's avatar
David Knowles committed
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092

  def GetNodes(self, bulk=False):
    """Gets all nodes in the cluster.

    @type bulk: bool
    @param bulk: whether to return all information about all instances

    @rtype: list of dict or str
    @return: if bulk is true, info about nodes in the cluster,
        else list of nodes in the cluster

    """
    query = []
    if bulk:
      query.append(("bulk", 1))

1093
1094
    nodes = self._SendRequest(HTTP_GET, "/%s/nodes" % GANETI_RAPI_VERSION,
                              query, None)
David Knowles's avatar
David Knowles committed
1095
1096
1097
1098
1099
    if bulk:
      return nodes
    else:
      return [n["id"] for n in nodes]

1100
  def GetNode(self, node):
David Knowles's avatar
David Knowles committed
1101
1102
1103
1104
1105
1106
1107
1108
1109
    """Gets information about a node.

    @type node: str
    @param node: node whose info to return

    @rtype: dict
    @return: info about the node

    """
1110
1111
1112
    return self._SendRequest(HTTP_GET,
                             "/%s/nodes/%s" % (GANETI_RAPI_VERSION, node),
                             None, None)
David Knowles's avatar
David Knowles committed
1113
1114

  def EvacuateNode(self, node, iallocator=None, remote_node=None,
1115
                   dry_run=False, early_release=False):
David Knowles's avatar
David Knowles committed
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
    """Evacuates instances from a Ganeti node.

    @type node: str
    @param node: node to evacuate
    @type iallocator: str or None
    @param iallocator: instance allocator to use
    @type remote_node: str
    @param remote_node: node to evaucate to
    @type dry_run: bool
    @param dry_run: whether to perform a dry run
1126
1127
    @type early_release: bool
    @param early_release: whether to enable parallelization
David Knowles's avatar
David Knowles committed
1128

1129
1130
1131
1132
    @rtype: list
    @return: list of (job ID, instance name, new secondary node); if
        dry_run was specified, then the actual move jobs were not
        submitted and the job IDs will be C{None}
David Knowles's avatar
David Knowles committed
1133

1134
1135
    @raises GanetiApiError: if an iallocator and remote_node are both
        specified
David Knowles's avatar
David Knowles committed
1136
1137
1138

    """
    if iallocator and remote_node:
1139
      raise GanetiApiError("Only one of iallocator or remote_node can be used")
David Knowles's avatar
David Knowles committed
1140

1141
    query = []
David Knowles's avatar
David Knowles committed
1142
1143
1144
1145
1146
1147
    if iallocator:
      query.append(("iallocator", iallocator))
    if remote_node:
      query.append(("remote_node", remote_node))
    if dry_run:
      query.append(("dry-run", 1))
1148
1149
    if early_release:
      query.append(("early_release", 1))
David Knowles's avatar
David Knowles committed
1150

1151
1152
1153
    return self._SendRequest(HTTP_POST,
                             ("/%s/nodes/%s/evacuate" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1154

1155
  def MigrateNode(self, node, mode=None, dry_run=False):
David Knowles's avatar
David Knowles committed
1156
1157
1158
1159
    """Migrates all primary instances from a node.

    @type node: str
    @param node: node to migrate
1160
1161
1162
    @type mode: string
    @param mode: if passed, it will overwrite the live migration type,
        otherwise the hypervisor default will be used
David Knowles's avatar
David Knowles committed
1163
1164
1165
1166
1167
1168
1169
1170
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = []
1171
1172
    if mode is not None:
      query.append(("mode", mode))
David Knowles's avatar
David Knowles committed
1173
1174
1175
    if dry_run:
      query.append(("dry-run", 1))

1176
1177
1178
    return self._SendRequest(HTTP_POST,
                             ("/%s/nodes/%s/migrate" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189

  def GetNodeRole(self, node):
    """Gets the current role for a node.

    @type node: str
    @param node: node whose role to return

    @rtype: str
    @return: the current role for a node

    """
1190
1191
1192
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/role" %
                              (GANETI_RAPI_VERSION, node)), None, None)
David Knowles's avatar
David Knowles committed
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207

  def SetNodeRole(self, node, role, force=False):
    """Sets the role for a node.

    @type node: str
    @param node: the node whose role to set
    @type role: str
    @param role: the role to set for the node
    @type force: bool
    @param force: whether to force the role change

    @rtype: int
    @return: job id

    """
1208
1209
1210
    query = [
      ("force", force),
      ]
1211

1212
1213
1214
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/role" %
                              (GANETI_RAPI_VERSION, node)), query, role)
David Knowles's avatar
David Knowles committed
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229

  def GetNodeStorageUnits(self, node, storage_type, output_fields):
    """Gets the storage units for a node.

    @type node: str
    @param node: the node whose storage units to return
    @type storage_type: str
    @param storage_type: storage type whose units to return
    @type output_fields: str
    @param output_fields: storage type fields to return

    @rtype: int
    @return: job id where results can be retrieved

    """
1230
1231
1232
1233
    query = [
      ("storage_type", storage_type),
      ("output_fields", output_fields),
      ]
David Knowles's avatar
David Knowles committed
1234

1235
1236
1237
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/storage" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1238

1239
  def ModifyNodeStorageUnits(self, node, storage_type, name, allocatable=None):
David Knowles's avatar
David Knowles committed
1240
1241
1242
1243
1244
1245
1246
1247
    """Modifies parameters of storage units on the node.

    @type node: str
    @param node: node whose storage units to modify
    @type storage_type: str
    @param storage_type: storage type whose units to modify
    @type name: str
    @param name: name of the storage unit
1248
1249
1250
    @type allocatable: bool or None
    @param allocatable: Whether to set the "allocatable" flag on the storage
                        unit (None=no modification, True=set, False=unset)
David Knowles's avatar
David Knowles committed
1251
1252
1253
1254
1255
1256

    @rtype: int
    @return: job id

    """
    query = [
1257
1258
1259
1260
      ("storage_type", storage_type),
      ("name", name),
      ]

1261
1262
1263
    if allocatable is not None:
      query.append(("allocatable", allocatable))

1264
1265
1266
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/storage/modify" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281

  def RepairNodeStorageUnits(self, node, storage_type, name):
    """Repairs a storage unit on the node.

    @type node: str
    @param node: node whose storage units to repair
    @type storage_type: str
    @param storage_type: storage type to repair
    @type name: str
    @param name: name of the storage unit to repair

    @rtype: int
    @return: job id

    """
1282
1283
1284
1285
    query = [
      ("storage_type", storage_type),
      ("name", name),
      ]
David Knowles's avatar
David Knowles committed
1286

1287
1288
1289
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/storage/repair" %
                              (GANETI_RAPI_VERSION, node)), query, None)
David Knowles's avatar
David Knowles committed
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300

  def GetNodeTags(self, node):
    """Gets the tags for a node.

    @type node: str
    @param node: node whose tags to return

    @rtype: list of str
    @return: tags for the node

    """
1301
1302
1303
    return self._SendRequest(HTTP_GET,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), None, None)
David Knowles's avatar
David Knowles committed
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322

  def AddNodeTags(self, node, tags, dry_run=False):
    """Adds tags to a node.

    @type node: str
    @param node: node to add tags to
    @type tags: list of str
    @param tags: tags to add to the node
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

1323
1324
1325
    return self._SendRequest(HTTP_PUT,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), query, tags)
David Knowles's avatar
David Knowles committed
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344

  def DeleteNodeTags(self, node, tags, dry_run=False):
    """Delete tags from a node.

    @type node: str
    @param node: node to remove tags from
    @type tags: list of str
    @param tags: tags to remove from the node
    @type dry_run: bool
    @param dry_run: whether to perform a dry run

    @rtype: int
    @return: job id

    """
    query = [("tag", t) for t in tags]
    if dry_run:
      query.append(("dry-run", 1))

1345
1346
1347
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/nodes/%s/tags" %
                              (GANETI_RAPI_VERSION, node)), query, None)