client.py 72.4 KB
Newer Older
David Knowles's avatar
David Knowles committed
1 2 3
#
#

4
# Copyright (C) 2010, 2011, 2012 Google Inc.
David Knowles's avatar
David Knowles committed
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
#
# 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
import simplejson
38
import socket
David Knowles's avatar
David Knowles committed
39
import urllib
40 41
import threading
import pycurl
42
import time
43 44 45 46 47

try:
  from cStringIO import StringIO
except ImportError:
  from StringIO import StringIO
David Knowles's avatar
David Knowles committed
48 49


50
GANETI_RAPI_PORT = 5080
51
GANETI_RAPI_VERSION = 2
52

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

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

66 67 68 69
NODE_EVAC_PRI = "primary-only"
NODE_EVAC_SEC = "secondary-only"
NODE_EVAC_ALL = "all"

70 71 72 73 74
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
75

76
JOB_STATUS_QUEUED = "queued"
77
JOB_STATUS_WAITING = "waiting"
78 79 80 81 82
JOB_STATUS_CANCELING = "canceling"
JOB_STATUS_RUNNING = "running"
JOB_STATUS_CANCELED = "canceled"
JOB_STATUS_SUCCESS = "success"
JOB_STATUS_ERROR = "error"
83 84 85 86 87
JOB_STATUS_PENDING = frozenset([
  JOB_STATUS_QUEUED,
  JOB_STATUS_WAITING,
  JOB_STATUS_CANCELING,
  ])
88 89 90 91 92 93 94
JOB_STATUS_FINALIZED = frozenset([
  JOB_STATUS_CANCELED,
  JOB_STATUS_SUCCESS,
  JOB_STATUS_ERROR,
  ])
JOB_STATUS_ALL = frozenset([
  JOB_STATUS_RUNNING,
95
  ]) | JOB_STATUS_PENDING | JOB_STATUS_FINALIZED
96

97 98 99
# Legacy name
JOB_STATUS_WAITLOCK = JOB_STATUS_WAITING

100 101
# Internal constants
_REQ_DATA_VERSION_FIELD = "__version__"
102 103
_QPARAM_DRY_RUN = "dry-run"
_QPARAM_FORCE = "force"
104

105 106 107 108 109 110 111 112 113 114 115 116
# Feature strings
INST_CREATE_REQV1 = "instance-create-reqv1"
INST_REINSTALL_REQV1 = "instance-reinstall-reqv1"
NODE_MIGRATE_REQV1 = "node-migrate-reqv1"
NODE_EVAC_RES1 = "node-evac-res1"

# Old feature constant names in case they're references by users of this module
_INST_CREATE_REQV1 = INST_CREATE_REQV1
_INST_REINSTALL_REQV1 = INST_REINSTALL_REQV1
_NODE_MIGRATE_REQV1 = NODE_MIGRATE_REQV1
_NODE_EVAC_RES1 = NODE_EVAC_RES1

117 118 119 120 121 122 123
#: Resolver errors
ECODE_RESOLVER = "resolver_error"

#: Not enough resources (iallocator failure, disk space, memory, etc.)
ECODE_NORES = "insufficient_resources"

#: Temporarily out of resources; operation can be tried again
124
ECODE_TEMP_NORES = "temp_insufficient_resources"
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

#: Wrong arguments (at syntax level)
ECODE_INVAL = "wrong_input"

#: Wrong entity state
ECODE_STATE = "wrong_state"

#: Entity not found
ECODE_NOENT = "unknown_entity"

#: Entity already exists
ECODE_EXISTS = "already_exists"

#: Resource not unique (e.g. MAC or IP duplication)
ECODE_NOTUNIQUE = "resource_not_unique"

#: Internal cluster error
ECODE_FAULT = "internal_error"

#: Environment error (e.g. node disk error)
ECODE_ENVIRON = "environment_error"

#: List of all failure types
ECODE_ALL = frozenset([
  ECODE_RESOLVER,
  ECODE_NORES,
  ECODE_TEMP_NORES,
  ECODE_INVAL,
  ECODE_STATE,
  ECODE_NOENT,
  ECODE_EXISTS,
  ECODE_NOTUNIQUE,
  ECODE_FAULT,
  ECODE_ENVIRON,
  ])

161 162 163 164 165 166 167 168 169 170 171 172 173
# 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
174 175 176 177 178 179 180 181 182 183 184 185

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

  """
  pass


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

  """
186 187 188
  def __init__(self, msg, code=None):
    Error.__init__(self, msg)
    self.code = code
David Knowles's avatar
David Knowles committed
189 190


191 192 193 194 195 196 197
class CertificateError(GanetiApiError):
  """Raised when a problem is found with the SSL certificate.

  """
  pass


198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
def _AppendIf(container, condition, value):
  """Appends to a list if a condition evaluates to truth.

  """
  if condition:
    container.append(value)

  return condition


def _AppendDryRunIf(container, condition):
  """Appends a "dry-run" parameter if a condition evaluates to truth.

  """
  return _AppendIf(container, condition, (_QPARAM_DRY_RUN, 1))


def _AppendForceIf(container, condition):
  """Appends a "force" parameter if a condition evaluates to truth.

  """
  return _AppendIf(container, condition, (_QPARAM_FORCE, 1))


222 223 224 225 226 227 228 229 230
def _AppendReason(container, reason):
  """Appends an element to the reason trail.

  If the user provided a reason, it is added to the reason trail.

  """
  return _AppendIf(container, reason, ("reason", reason))


231 232 233 234 235 236 237 238 239 240
def _SetItemIf(container, condition, item, value):
  """Sets an item if a condition evaluates to truth.

  """
  if condition:
    container[item] = value

  return condition


241 242
def UsesRapiClient(fn):
  """Decorator for code using RAPI client to initialize pycURL.
243 244

  """
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
  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)).
289 290

  """
291 292
  if use_curl_cabundle and (cafile or capath):
    raise Error("Can not use default CA bundle when CA file or path is set")
293

294 295
  def _ConfigCurl(curl, logger):
    """Configures a cURL object
296

297 298
    @type curl: pycurl.Curl
    @param curl: cURL object
299 300

    """
301 302 303 304 305 306 307 308 309 310 311 312 313
    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
314 315 316
    elif lcsslver.startswith("nss/"):
      # TODO: investigate compatibility beyond a simple test
      pass
317 318 319 320
    elif lcsslver.startswith("gnutls/"):
      if capath:
        raise Error("cURL linked against GnuTLS has no support for a"
                    " CA path (%s)" % (pycurl.version, ))
321
    else:
322 323 324 325 326 327 328 329 330 331 332 333 334 335
      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)
336
    else:
337 338 339 340 341 342 343 344 345 346 347 348 349
      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)
350

351 352
    if proxy is not None:
      curl.setopt(pycurl.PROXY, str(proxy))
353

354 355 356 357 358
    # Timeouts
    if connect_timeout is not None:
      curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
    if timeout is not None:
      curl.setopt(pycurl.TIMEOUT, timeout)
359

360
  return _ConfigCurl
361 362


363
class GanetiRapiClient(object): # pylint: disable=R0904
David Knowles's avatar
David Knowles committed
364 365 366 367
  """Ganeti RAPI client.

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

370
  def __init__(self, host, port=GANETI_RAPI_PORT,
371
               username=None, password=None, logger=logging,
372
               curl_config_fn=None, curl_factory=None):
373
    """Initializes this class.
David Knowles's avatar
David Knowles committed
374

375 376
    @type host: string
    @param host: the ganeti cluster master to interact with
David Knowles's avatar
David Knowles committed
377
    @type port: int
378 379
    @param port: the port on which the RAPI is running (default is 5080)
    @type username: string
David Knowles's avatar
David Knowles committed
380
    @param username: the username to connect with
381
    @type password: string
David Knowles's avatar
David Knowles committed
382
    @param password: the password to connect with
383 384
    @type curl_config_fn: callable
    @param curl_config_fn: Function to configure C{pycurl.Curl} object
385
    @param logger: Logging object
David Knowles's avatar
David Knowles committed
386 387

    """
388 389
    self._username = username
    self._password = password
390
    self._logger = logger
391 392
    self._curl_config_fn = curl_config_fn
    self._curl_factory = curl_factory
David Knowles's avatar
David Knowles committed
393

394 395 396 397 398 399 400
    try:
      socket.inet_pton(socket.AF_INET6, host)
      address = "[%s]:%s" % (host, port)
    except socket.error:
      address = "%s:%s" % (host, port)

    self._base_url = "https://%s" % address
David Knowles's avatar
David Knowles committed
401

402 403 404 405 406 407 408 409 410 411 412 413 414 415
    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:
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430
      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,
      ])

431 432 433 434 435
    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
436
      curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
437 438
      curl.setopt(pycurl.USERPWD,
                  str("%s:%s" % (self._username, self._password)))
439

440
    # Call external configuration function
441 442
    if self._curl_config_fn:
      self._curl_config_fn(curl, self._logger)
David Knowles's avatar
David Knowles committed
443

444
    return curl
David Knowles's avatar
David Knowles committed
445

446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473
  @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

474
  def _SendRequest(self, method, path, query, content):
David Knowles's avatar
David Knowles committed
475 476 477 478 479
    """Sends an HTTP request.

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

480
    @type method: string
David Knowles's avatar
David Knowles committed
481
    @param method: HTTP method to use
482
    @type path: string
David Knowles's avatar
David Knowles committed
483 484 485 486 487 488 489 490 491
    @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
492
    @raises CertificateError: If an invalid SSL certificate is found
David Knowles's avatar
David Knowles committed
493 494 495
    @raises GanetiApiError: If an invalid response is returned

    """
496 497
    assert path.startswith("/")

498
    curl = self._CreateCurl()
499

500
    if content is not None:
501 502
      encoded_content = self._json_encoder.encode(content)
    else:
503
      encoded_content = ""
David Knowles's avatar
David Knowles committed
504

505
    # Build URL
506
    urlparts = [self._base_url, path]
507
    if query:
508 509
      urlparts.append("?")
      urlparts.append(urllib.urlencode(self._EncodeQuery(query)))
510

511 512
    url = "".join(urlparts)

513 514
    self._logger.debug("Sending request %s %s (content=%r)",
                       method, url, encoded_content)
515 516 517

    # Buffer for response
    encoded_resp_body = StringIO()
518

519 520 521 522 523
    # 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)
524

David Knowles's avatar
David Knowles committed
525
    try:
526 527 528 529 530
      # Send request and wait for response
      try:
        curl.perform()
      except pycurl.error, err:
        if err.args[0] in _CURL_SSL_CERT_ERRORS:
531 532
          raise CertificateError("SSL certificate error %s" % err,
                                 code=err.args[0])
533

534
        raise GanetiApiError(str(err), code=err.args[0])
535 536 537 538 539 540 541 542 543 544 545 546
    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())
547 548
    else:
      response_content = None
David Knowles's avatar
David Knowles committed
549

550
    if http_code != HTTP_OK:
551
      if isinstance(response_content, dict):
David Knowles's avatar
David Knowles committed
552
        msg = ("%s %s: %s" %
553 554 555
               (response_content["code"],
                response_content["message"],
                response_content["explain"]))
David Knowles's avatar
David Knowles committed
556
      else:
557 558
        msg = str(response_content)

559
      raise GanetiApiError(msg, code=http_code)
David Knowles's avatar
David Knowles committed
560

561
    return response_content
David Knowles's avatar
David Knowles committed
562 563

  def GetVersion(self):
564
    """Gets the Remote API version running on the cluster.
David Knowles's avatar
David Knowles committed
565 566

    @rtype: int
David Knowles's avatar
David Knowles committed
567
    @return: Ganeti Remote API version
David Knowles's avatar
David Knowles committed
568 569

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

572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588
  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

589
  def GetOperatingSystems(self, reason=None):
David Knowles's avatar
David Knowles committed
590 591 592 593
    """Gets the Operating Systems running in the Ganeti cluster.

    @rtype: list of str
    @return: operating systems
594 595
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
596 597

    """
598 599
    query = []
    _AppendReason(query, reason)
600
    return self._SendRequest(HTTP_GET, "/%s/os" % GANETI_RAPI_VERSION,
601
                             query, None)
David Knowles's avatar
David Knowles committed
602

603
  def GetInfo(self, reason=None):
David Knowles's avatar
David Knowles committed
604 605
    """Gets info about the cluster.

606 607
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
608 609 610 611
    @rtype: dict
    @return: information about the cluster

    """
612 613
    query = []
    _AppendReason(query, reason)
614
    return self._SendRequest(HTTP_GET, "/%s/info" % GANETI_RAPI_VERSION,
615
                             query, None)
David Knowles's avatar
David Knowles committed
616

617
  def RedistributeConfig(self, reason=None):
618 619
    """Tells the cluster to redistribute its configuration files.

620 621
    @type reason: string
    @param reason: the reason for executing this operation
622
    @rtype: string
623 624 625
    @return: job id

    """
626 627
    query = []
    _AppendReason(query, reason)
628 629
    return self._SendRequest(HTTP_PUT,
                             "/%s/redistribute-config" % GANETI_RAPI_VERSION,
630
                             query, None)
631

632
  def ModifyCluster(self, reason=None, **kwargs):
633 634 635 636
    """Modifies cluster parameters.

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

637 638
    @type reason: string
    @param reason: the reason for executing this operation
639
    @rtype: string
640 641 642
    @return: job id

    """
643 644 645
    query = []
    _AppendReason(query, reason)

646 647 648
    body = kwargs

    return self._SendRequest(HTTP_PUT,
649
                             "/%s/modify" % GANETI_RAPI_VERSION, query, body)
650

651
  def GetClusterTags(self, reason=None):
David Knowles's avatar
David Knowles committed
652 653
    """Gets the cluster tags.

654 655
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
656 657 658 659
    @rtype: list of str
    @return: cluster tags

    """
660 661
    query = []
    _AppendReason(query, reason)
662
    return self._SendRequest(HTTP_GET, "/%s/tags" % GANETI_RAPI_VERSION,
663
                             query, None)
David Knowles's avatar
David Knowles committed
664

665
  def AddClusterTags(self, tags, dry_run=False, reason=None):
David Knowles's avatar
David Knowles committed
666 667 668 669 670 671
    """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
672 673
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
674

675
    @rtype: string
David Knowles's avatar
David Knowles committed
676 677 678 679
    @return: job id

    """
    query = [("tag", t) for t in tags]
680
    _AppendDryRunIf(query, dry_run)
681
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
682

683 684
    return self._SendRequest(HTTP_PUT, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
685

686
  def DeleteClusterTags(self, tags, dry_run=False, reason=None):
David Knowles's avatar
David Knowles committed
687 688 689 690 691 692
    """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
693 694
    @type reason: string
    @param reason: the reason for executing this operation
695 696
    @rtype: string
    @return: job id
David Knowles's avatar
David Knowles committed
697 698 699

    """
    query = [("tag", t) for t in tags]
700
    _AppendDryRunIf(query, dry_run)
701
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
702

703 704
    return self._SendRequest(HTTP_DELETE, "/%s/tags" % GANETI_RAPI_VERSION,
                             query, None)
David Knowles's avatar
David Knowles committed
705

706
  def GetInstances(self, bulk=False, reason=None):
David Knowles's avatar
David Knowles committed
707 708 709 710
    """Gets information about instances on the cluster.

    @type bulk: bool
    @param bulk: whether to return all information about all instances
711 712
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
713 714 715 716 717 718

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

    """
    query = []
719
    _AppendIf(query, bulk, ("bulk", 1))
720
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
721

722 723 724
    instances = self._SendRequest(HTTP_GET,
                                  "/%s/instances" % GANETI_RAPI_VERSION,
                                  query, None)
David Knowles's avatar
David Knowles committed
725 726 727 728 729
    if bulk:
      return instances
    else:
      return [i["id"] for i in instances]

730
  def GetInstance(self, instance, reason=None):
David Knowles's avatar
David Knowles committed
731 732 733 734
    """Gets information about an instance.

    @type instance: str
    @param instance: instance whose info to return
735 736
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
737 738 739 740 741

    @rtype: dict
    @return: info about the instance

    """
742 743 744
    query = []
    _AppendReason(query, reason)

745 746
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s" %
747
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
748

749
  def GetInstanceInfo(self, instance, static=None, reason=None):
750 751 752 753
    """Gets information about an instance.

    @type instance: string
    @param instance: Instance name
754 755
    @type reason: string
    @param reason: the reason for executing this operation
756 757 758 759
    @rtype: string
    @return: Job ID

    """
760
    query = []
761
    if static is not None:
762 763
      query.append(("static", static))
    _AppendReason(query, reason)
764 765 766 767 768

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

769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820
  @staticmethod
  def _UpdateWithKwargs(base, **kwargs):
    """Updates the base with params from kwargs.

    @param base: The base dict, filled with required fields

    @note: This is an inplace update of base

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

    base.update((key, value) for key, value in kwargs.iteritems()
                if key != "dry_run")

  def InstanceAllocation(self, mode, name, disk_template, disks, nics,
                         **kwargs):
    """Generates an instance allocation as used by multiallocate.

    More details for parameters can be found in the RAPI documentation.
    It is the same as used by CreateInstance.

    @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

    @return: A dict with the generated entry

    """
    # All required fields for request data version 1
    alloc = {
      "mode": mode,
      "name": name,
      "disk_template": disk_template,
      "disks": disks,
      "nics": nics,
      }

    self._UpdateWithKwargs(alloc, **kwargs)

    return alloc

821
  def InstancesMultiAlloc(self, instances, reason=None, **kwargs):
822 823 824 825 826 827 828 829 830 831 832 833 834 835
    """Tries to allocate multiple instances.

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

    @param instances: A list of L{InstanceAllocation} results

    """
    query = []
    body = {
      "instances": instances,
      }
    self._UpdateWithKwargs(body, **kwargs)

    _AppendDryRunIf(query, kwargs.get("dry_run"))
836
    _AppendReason(query, reason)
837 838 839 840 841

    return self._SendRequest(HTTP_POST,
                             "/%s/instances-multi-alloc" % GANETI_RAPI_VERSION,
                             query, body)

842
  def CreateInstance(self, mode, name, disk_template, disks, nics,
843
                     reason=None, **kwargs):
David Knowles's avatar
David Knowles committed
844 845
    """Creates a new instance.

846 847 848 849 850 851 852 853 854 855 856 857 858
    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
859
    @type dry_run: bool
860
    @keyword dry_run: whether to perform a dry run
861 862
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
863

864
    @rtype: string
David Knowles's avatar
David Knowles committed
865 866 867 868
    @return: job id

    """
    query = []
869

870
    _AppendDryRunIf(query, kwargs.get("dry_run"))
871
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
872

873
    if _INST_CREATE_REQV1 in self.GetFeatures():
874 875 876
      body = self.InstanceAllocation(mode, name, disk_template, disks, nics,
                                     **kwargs)
      body[_REQ_DATA_VERSION_FIELD] = 1
877
    else:
878 879
      raise GanetiApiError("Server does not support new-style (version 1)"
                           " instance creation requests")
880

881
    return self._SendRequest(HTTP_POST, "/%s/instances" % GANETI_RAPI_VERSION,
882
                             query, body)
David Knowles's avatar
David Knowles committed
883

884
  def DeleteInstance(self, instance, dry_run=False, reason=None, **kwargs):
David Knowles's avatar
David Knowles committed
885 886 887 888
    """Deletes an instance.

    @type instance: str
    @param instance: the instance to delete
889 890
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
891

892
    @rtype: string
893 894
    @return: job id

David Knowles's avatar
David Knowles committed
895 896
    """
    query = []
897 898
    body = kwargs

899
    _AppendDryRunIf(query, dry_run)
900
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
901

902 903
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s" %
904
                              (GANETI_RAPI_VERSION, instance)), query, body)
David Knowles's avatar
David Knowles committed
905

906
  def ModifyInstance(self, instance, reason=None, **kwargs):
907 908 909 910 911 912
    """Modifies an instance.

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

    @type instance: string
    @param instance: Instance name
913 914
    @type reason: string
    @param reason: the reason for executing this operation
915
    @rtype: string
916 917 918 919
    @return: job id

    """
    body = kwargs
920 921
    query = []
    _AppendReason(query, reason)
922 923 924

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

927
  def ActivateInstanceDisks(self, instance, ignore_size=None, reason=None):
928 929 930 931 932 933
    """Activates an instance's disks.

    @type instance: string
    @param instance: Instance name
    @type ignore_size: bool
    @param ignore_size: Whether to ignore recorded size
934 935
    @type reason: string
    @param reason: the reason for executing this operation
936
    @rtype: string
937 938 939 940
    @return: job id

    """
    query = []
941
    _AppendIf(query, ignore_size, ("ignore_size", 1))
942
    _AppendReason(query, reason)
943 944 945 946 947

    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/activate-disks" %
                              (GANETI_RAPI_VERSION, instance)), query, None)

948
  def DeactivateInstanceDisks(self, instance, reason=None):
949 950 951 952
    """Deactivates an instance's disks.

    @type instance: string
    @param instance: Instance name
953 954
    @type reason: string
    @param reason: the reason for executing this operation
955
    @rtype: string
956 957 958
    @return: job id

    """
959 960
    query = []
    _AppendReason(query, reason)
961 962
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/deactivate-disks" %
963
                              (GANETI_RAPI_VERSION, instance)), query, None)
964

965 966
  def RecreateInstanceDisks(self, instance, disks=None, nodes=None,
                            reason=None):
967 968 969 970 971 972 973 974
    """Recreate an instance's disks.

    @type instance: string
    @param instance: Instance name
    @type disks: list of int
    @param disks: List of disk indexes
    @type nodes: list of string
    @param nodes: New instance nodes, if relocation is desired
975 976
    @type reason: string
    @param reason: the reason for executing this operation
977 978 979 980 981
    @rtype: string
    @return: job id

    """
    body = {}
982 983
    _SetItemIf(body, disks is not None, "disks", disks)
    _SetItemIf(body, nodes is not None, "nodes", nodes)
984

985 986 987
    query = []
    _AppendReason(query, reason)

988 989
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/recreate-disks" %
990
                              (GANETI_RAPI_VERSION, instance)), query, body)
991

992 993
  def GrowInstanceDisk(self, instance, disk, amount, wait_for_sync=None,
                       reason=None):
994 995 996 997 998 999 1000 1001 1002 1003 1004 1005
    """Grows a disk of an instance.

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

    @type instance: string
    @param instance: Instance name
    @type disk: integer
    @param disk: Disk index
    @type amount: integer
    @param amount: Grow disk by this amount (MiB)
    @type wait_for_sync: bool
    @param wait_for_sync: Wait for disk to synchronize
1006 1007
    @type reason: string
    @param reason: the reason for executing this operation
1008
    @rtype: string
1009 1010 1011 1012 1013 1014 1015
    @return: job id

    """
    body = {
      "amount": amount,
      }

1016
    _SetItemIf(body, wait_for_sync is not None, "wait_for_sync", wait_for_sync)
1017

1018 1019 1020
    query = []
    _AppendReason(query, reason)

1021 1022 1023
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/disk/%s/grow" %
                              (GANETI_RAPI_VERSION, instance, disk)),
1024
                             query, body)
1025

1026
  def GetInstanceTags(self, instance, reason=None):
David Knowles's avatar
David Knowles committed
1027 1028 1029 1030
    """Gets tags for an instance.

    @type instance: str
    @param instance: instance whose tags to return
1031 1032
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
1033 1034 1035 1036 1037

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

    """
1038 1039
    query = []
    _AppendReason(query, reason)
1040 1041
    return self._SendRequest(HTTP_GET,
                             ("/%s/instances/%s/tags" %
1042
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
1043

1044
  def AddInstanceTags(self, instance, tags, dry_run=False, reason=None):
David Knowles's avatar
David Knowles committed
1045 1046 1047 1048 1049 1050 1051 1052
    """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
1053 1054
    @type reason: string
    @param reason: the reason for executing this operation
David Knowles's avatar
David Knowles committed
1055

1056
    @rtype: string
David Knowles's avatar
David Knowles committed
1057 1058 1059 1060
    @return: job id

    """
    query = [("tag", t) for t in tags]
1061
    _AppendDryRunIf(query, dry_run)
1062
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
1063

1064 1065 1066
    return self._SendRequest(HTTP_PUT,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
1067

1068
  def DeleteInstanceTags(self, instance, tags, dry_run=False, reason=None):
David Knowles's avatar
David Knowles committed
1069 1070 1071 1072 1073 1074 1075 1076
    """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
1077 1078
    @type reason: string
    @param reason: the reason for executing this operation
1079 1080
    @rtype: string
    @return: job id
David Knowles's avatar
David Knowles committed
1081 1082 1083

    """
    query = [("tag", t) for t in tags]
1084
    _AppendDryRunIf(query, dry_run)
1085
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
1086

1087 1088 1089
    return self._SendRequest(HTTP_DELETE,
                             ("/%s/instances/%s/tags" %
                              (GANETI_RAPI_VERSION, instance)), query, None)
David Knowles's avatar
David Knowles committed
1090 1091

  def RebootInstance(self, instance, reboot_type=None, ignore_secondaries=None,
1092
                     dry_run=False, reason=None, **kwargs):
David Knowles's avatar
David Knowles committed
1093 1094 1095
    """Reboots an instance.

    @type instance: str
1096
    @param instance: instance to reboot
David Knowles's avatar
David Knowles committed
1097 1098 1099 1100 1101 1102 1103
    @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
1104 1105
    @type reason: string
    @param reason: the reason for the reboot
1106 1107
    @rtype: string
    @return: job id
David Knowles's avatar
David Knowles committed
1108 1109 1110

    """
    query = []
1111 1112
    body = kwargs

1113 1114 1115 1116
    _AppendDryRunIf(query, dry_run)
    _AppendIf(query, reboot_type, ("type", reboot_type))
    _AppendIf(query, ignore_secondaries is not None,
              ("ignore_secondaries", ignore_secondaries))
1117
    _AppendReason(query, reason)
David Knowles's avatar
David Knowles committed
1118

1119 1120
    return self._SendRequest(HTTP_POST,
                             ("/%s/instances/%s/reboot" %
1121
                              (GANETI_RAPI_VERSION, instance)), query, body)
David Knowles's avatar
David Knowles committed
1122

1123
  def ShutdownInstance(self, instance, dry_run=False, no_remember=False,
1124
                       reason=None, **kwargs):
David Knowles's avatar
David Knowles committed
1125 1126 1127 1128 1129 1130
    """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
1131 1132
    @type no_remember: bool
    @param no_remember: if true, will not record the state change