slap.py 39.4 KB
Newer Older
Łukasz Nowak's avatar
Łukasz Nowak committed
1
# -*- coding: utf-8 -*-
2
# vim: set et sts=2:
Łukasz Nowak's avatar
Łukasz Nowak committed
3 4
##############################################################################
#
5 6
# Copyright (c) 2010, 2011, 2012 Vifib SARL and Contributors.
# All Rights Reserved.
Łukasz Nowak's avatar
Łukasz Nowak committed
7 8 9 10 11 12 13 14 15
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
16 17
# modify it under the terms of the GNU Lesser General Public License
# as published by the Free Software Foundation; either version 2.1
Łukasz Nowak's avatar
Łukasz Nowak committed
18 19 20 21 22 23 24
# 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.
#
25
# You should have received a copy of the GNU Lesser General Public License
Łukasz Nowak's avatar
Łukasz Nowak committed
26 27 28 29
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################
30 31 32 33
"""
Simple, easy to (un)marshall classes for slap client/server communication
"""

Łukasz Nowak's avatar
Łukasz Nowak committed
34
__all__ = ["slap", "ComputerPartition", "Computer", "SoftwareRelease",
35
           "SoftwareInstance", "SoftwareProductCollection",
36
           "Supply", "OpenOrder", "NotFoundError",
37
           "ResourceNotReady", "ServerError", "ConnectionError"]
Łukasz Nowak's avatar
Łukasz Nowak committed
38

39
import os
40
import json
41
import logging
42
import re
Łukasz Nowak's avatar
Łukasz Nowak committed
43
import urlparse
44
import hashlib
45
from util import xml2dict
46

47
import netaddr
48
from xml.sax import saxutils
Łukasz Nowak's avatar
Łukasz Nowak committed
49
import zope.interface
50 51 52
from interface import slap as interface
from xml_marshaller import xml_marshaller

53 54
from uritemplate import expand

55
import requests
56 57
# silence messages like 'Unverified HTTPS request is being made'
requests.packages.urllib3.disable_warnings()
58 59 60 61 62
# silence messages like 'Starting connection' that are logged with INFO
urllib3_logger = logging.getLogger('requests.packages.urllib3')
urllib3_logger.setLevel(logging.WARNING)


Marco Mariani's avatar
Marco Mariani committed
63
# XXX fallback_logger to be deprecated together with the old CLI entry points.
Marco Mariani's avatar
Marco Mariani committed
64
fallback_logger = logging.getLogger(__name__)
65 66 67
fallback_handler = logging.StreamHandler()
fallback_logger.setLevel(logging.INFO)
fallback_logger.addHandler(fallback_handler)
Łukasz Nowak's avatar
Łukasz Nowak committed
68 69


70
DEFAULT_SOFTWARE_TYPE = 'RootSoftwareInstance'
71
COMPUTER_PARTITION_REQUEST_LIST_TEMPLATE_FILENAME = '.slapos-request-transaction-%s'
72

Łukasz Nowak's avatar
Łukasz Nowak committed
73
class SlapDocument:
74
  def __init__(self, connection_helper=None, hateoas_navigator=None):
75 76 77 78
    if connection_helper is not None:
      # Do not require connection_helper to be provided, but when it's not,
      # cause failures when accessing _connection_helper property.
      self._connection_helper = connection_helper
79
      self._hateoas_navigator = hateoas_navigator
Łukasz Nowak's avatar
Łukasz Nowak committed
80

Marco Mariani's avatar
Marco Mariani committed
81

82 83 84 85
class SlapRequester(SlapDocument):
  """
  Abstract class that allow to factor method for subclasses that use "request()"
  """
86

87 88
  def _requestComputerPartition(self, request_dict):
    try:
89
      xml = self._connection_helper.POST('requestComputerPartition', data=request_dict)
90 91 92 93 94
    except ResourceNotReady:
      return ComputerPartition(
        request_dict=request_dict,
        connection_helper=self._connection_helper,
      )
95 96 97
    if type(xml) is unicode:
      xml = str(xml)
      xml.encode('utf-8')
98 99 100 101 102 103 104
    software_instance = xml_marshaller.loads(xml)
    computer_partition = ComputerPartition(
      software_instance.slap_computer_id.encode('UTF-8'),
      software_instance.slap_computer_partition_id.encode('UTF-8'),
      connection_helper=self._connection_helper,
    )
    # Hack to give all object attributes to the ComputerPartition instance
Cédric de Saint Martin's avatar
Cédric de Saint Martin committed
105 106 107 108
    # XXX Should be removed by correctly specifying difference between
    # ComputerPartition and SoftwareInstance
    computer_partition.__dict__ = dict(computer_partition.__dict__.items() +
                                       software_instance.__dict__.items())
109 110 111 112 113 114
    # XXX not generic enough.
    if xml_marshaller.loads(request_dict['shared_xml']):
      computer_partition._synced = True
      computer_partition._connection_dict = software_instance._connection_dict
      computer_partition._parameter_dict = software_instance._parameter_dict
    return computer_partition
115 116


Łukasz Nowak's avatar
Łukasz Nowak committed
117 118 119 120 121 122 123 124 125 126 127 128
class SoftwareRelease(SlapDocument):
  """
  Contains Software Release information
  """
  zope.interface.implements(interface.ISoftwareRelease)

  def __init__(self, software_release=None, computer_guid=None, **kw):
    """
    Makes easy initialisation of class parameters

    XXX **kw args only kept for compatibility
    """
129 130
    SlapDocument.__init__(self, kw.pop('connection_helper', None),
                                kw.pop('hateoas_navigator', None))
Łukasz Nowak's avatar
Łukasz Nowak committed
131 132 133 134 135 136 137 138 139
    self._software_instance_list = []
    if software_release is not None:
      software_release = software_release.encode('UTF-8')
    self._software_release = software_release
    self._computer_guid = computer_guid

  def __getinitargs__(self):
    return (self._software_release, self._computer_guid, )

140 141 142 143 144 145 146 147 148 149 150 151
  def getComputerId(self):
    if not self._computer_guid:
      raise NameError('computer_guid has not been defined.')
    else:
      return self._computer_guid

  def getURI(self):
    if not self._software_release:
      raise NameError('software_release has not been defined.')
    else:
      return self._software_release

152
  def error(self, error_log, logger=None):
153 154
    try:
      # Does not follow interface
155
      self._connection_helper.POST('softwareReleaseError', data={
156
        'url': self.getURI(),
Marco Mariani's avatar
Marco Mariani committed
157
        'computer_id': self.getComputerId(),
158 159
        'error_log': error_log})
    except Exception:
160
      (logger or fallback_logger).exception('')
Łukasz Nowak's avatar
Łukasz Nowak committed
161 162

  def available(self):
163
    self._connection_helper.POST('availableSoftwareRelease', data={
164 165
      'url': self.getURI(),
      'computer_id': self.getComputerId()})
Łukasz Nowak's avatar
Łukasz Nowak committed
166 167

  def building(self):
168
    self._connection_helper.POST('buildingSoftwareRelease', data={
169 170
      'url': self.getURI(),
      'computer_id': self.getComputerId()})
Łukasz Nowak's avatar
Łukasz Nowak committed
171

172
  def destroyed(self):
173
    self._connection_helper.POST('destroyedSoftwareRelease', data={
174 175
      'url': self.getURI(),
      'computer_id': self.getComputerId()})
176

177 178 179
  def getState(self):
    return getattr(self, '_requested_state', 'available')

Marco Mariani's avatar
Marco Mariani committed
180

181 182 183 184 185 186
class SoftwareProductCollection(object):
  zope.interface.implements(interface.ISoftwareProductCollection)

  def __init__(self, logger, slap):
    self.logger = logger
    self.slap = slap
187 188 189
    self.get = self.__getattr__

  def __getattr__(self, software_product):
Sebastien Robin's avatar
Sebastien Robin committed
190
      self.logger.info('Getting best Software Release corresponding to '
191 192 193 194 195 196 197 198 199 200 201 202
                       'this Software Product...')
      software_release_list = \
          self.slap.getSoftwareReleaseListFromSoftwareProduct(software_product)
      try:
          software_release_url = software_release_list[0] # First is best one.
          self.logger.info('Found as %s.' % software_release_url)
          return software_release_url
      except IndexError:
          raise AttributeError('No Software Release corresponding to this '
                           'Software Product has been found.')


Łukasz Nowak's avatar
Łukasz Nowak committed
203 204 205 206 207
# XXX What is this SoftwareInstance class?
class SoftwareInstance(SlapDocument):
  """
  Contains Software Instance information
  """
208
  zope.interface.implements(interface.ISoftwareInstance)
Łukasz Nowak's avatar
Łukasz Nowak committed
209 210 211 212 213 214 215 216

  def __init__(self, **kwargs):
    """
    Makes easy initialisation of class parameters
    """
    for k, v in kwargs.iteritems():
      setattr(self, k, v)

Marco Mariani's avatar
Marco Mariani committed
217

Łukasz Nowak's avatar
Łukasz Nowak committed
218 219
"""Exposed exceptions"""
class ResourceNotReady(Exception):
220
  zope.interface.implements(interface.IResourceNotReady)
Łukasz Nowak's avatar
Łukasz Nowak committed
221 222

class ServerError(Exception):
223
  zope.interface.implements(interface.IServerError)
Łukasz Nowak's avatar
Łukasz Nowak committed
224 225 226 227

class NotFoundError(Exception):
  zope.interface.implements(interface.INotFoundError)

228 229 230 231 232 233 234
class AuthenticationError(Exception):
  pass


class ConnectionError(Exception):
  zope.interface.implements(interface.IConnectionError)

Marco Mariani's avatar
Marco Mariani committed
235 236

class Supply(SlapDocument):
Łukasz Nowak's avatar
Łukasz Nowak committed
237 238
  zope.interface.implements(interface.ISupply)

239
  def supply(self, software_release, computer_guid=None, state='available'):
240
    try:
241
      self._connection_helper.POST('supplySupply', data={
242 243 244 245 246 247
        'url': software_release,
        'computer_id': computer_guid,
        'state': state})
    except NotFoundError:
      raise NotFoundError("Computer %s has not been found by SlapOS Master."
          % computer_guid)
Łukasz Nowak's avatar
Łukasz Nowak committed
248 249


Marco Mariani's avatar
Marco Mariani committed
250
class OpenOrder(SlapRequester):
Łukasz Nowak's avatar
Łukasz Nowak committed
251 252 253
  zope.interface.implements(interface.IOpenOrder)

  def request(self, software_release, partition_reference,
Marco Mariani's avatar
Marco Mariani committed
254 255
              partition_parameter_kw=None, software_type=None,
              filter_kw=None, state=None, shared=False):
Łukasz Nowak's avatar
Łukasz Nowak committed
256 257
    if partition_parameter_kw is None:
      partition_parameter_kw = {}
258 259
    if filter_kw is None:
      filter_kw = {}
Łukasz Nowak's avatar
Łukasz Nowak committed
260 261 262 263
    request_dict = {
        'software_release': software_release,
        'partition_reference': partition_reference,
        'partition_parameter_xml': xml_marshaller.dumps(partition_parameter_kw),
264
        'filter_xml': xml_marshaller.dumps(filter_kw),
265 266
        # XXX Cedric: Why state and shared are marshalled? First is a string
        #             And second is a boolean.
267
        'state': xml_marshaller.dumps(state),
268
        'shared_xml': xml_marshaller.dumps(shared),
Marco Mariani's avatar
Marco Mariani committed
269
    }
Łukasz Nowak's avatar
Łukasz Nowak committed
270 271
    if software_type is not None:
      request_dict['software_type'] = software_type
272 273 274
    else:
      # Let's enforce a default software type
      request_dict['software_type'] = DEFAULT_SOFTWARE_TYPE
275
    return self._requestComputerPartition(request_dict)
Łukasz Nowak's avatar
Łukasz Nowak committed
276

277 278
  def getInformation(self, partition_reference):
    if not getattr(self, '_hateoas_navigator', None):
279
      raise Exception('SlapOS Master Hateoas API required for this operation is not availble.')
280 281 282 283 284 285 286 287 288 289
    raw_information = self._hateoas_navigator.getHostingSubscriptionRootSoftwareInstanceInformation(partition_reference)
    software_instance = SoftwareInstance()
    # XXX redefine SoftwareInstance to be more consistent
    for key, value in raw_information.iteritems():
      if key in ['_links']:
        continue
      setattr(software_instance, '_%s' % key, value)
    setattr(software_instance, '_software_release_url', raw_information['_links']['software_release'])
    return software_instance

290 291 292 293
  def requestComputer(self, computer_reference):
    """
    Requests a computer.
    """
294
    xml = self._connection_helper.POST('requestComputer', data={'computer_title': computer_reference})
295 296
    computer = xml_marshaller.loads(xml)
    computer._connection_helper = self._connection_helper
297
    computer._hateoas_navigator = self._hateoas_navigator
298 299
    return computer

Marco Mariani's avatar
Marco Mariani committed
300

Łukasz Nowak's avatar
Łukasz Nowak committed
301 302 303 304 305
def _syncComputerInformation(func):
  """
  Synchronize computer object with server information
  """
  def decorated(self, *args, **kw):
306 307
    if getattr(self, '_synced', 0):
      return func(self, *args, **kw)
308
    computer = self._connection_helper.getFullComputerInformation(self._computer_id)
Łukasz Nowak's avatar
Łukasz Nowak committed
309 310 311 312 313 314
    for key, value in computer.__dict__.items():
      if isinstance(value, unicode):
        # convert unicode to utf-8
        setattr(self, key, value.encode('utf-8'))
      else:
        setattr(self, key, value)
315 316 317
    setattr(self, '_synced', True)
    for computer_partition in self.getComputerPartitionList():
      setattr(computer_partition, '_synced', True)
Łukasz Nowak's avatar
Łukasz Nowak committed
318
    return func(self, *args, **kw)
319
  return decorated
Łukasz Nowak's avatar
Łukasz Nowak committed
320 321


Marco Mariani's avatar
Marco Mariani committed
322
class Computer(SlapDocument):
Łukasz Nowak's avatar
Łukasz Nowak committed
323 324
  zope.interface.implements(interface.IComputer)

325 326
  def __init__(self, computer_id, connection_helper=None, hateoas_navigator=None):
    SlapDocument.__init__(self, connection_helper, hateoas_navigator)
Łukasz Nowak's avatar
Łukasz Nowak committed
327 328 329 330 331 332 333 334 335 336 337 338 339
    self._computer_id = computer_id

  def __getinitargs__(self):
    return (self._computer_id, )

  @_syncComputerInformation
  def getSoftwareReleaseList(self):
    """
    Returns the list of software release which has to be supplied by the
    computer.

    Raise an INotFoundError if computer_guid doesn't exist.
    """
340 341
    for software_relase in self._software_release_list:
      software_relase._connection_helper = self._connection_helper
342
      software_relase._hateoas_navigator = self._hateoas_navigator
Łukasz Nowak's avatar
Łukasz Nowak committed
343 344 345 346
    return self._software_release_list

  @_syncComputerInformation
  def getComputerPartitionList(self):
347 348
    for computer_partition in self._computer_partition_list:
      computer_partition._connection_helper = self._connection_helper
349
      computer_partition._hateoas_navigator = self._hateoas_navigator
Marco Mariani's avatar
Marco Mariani committed
350
    return [x for x in self._computer_partition_list]
Łukasz Nowak's avatar
Łukasz Nowak committed
351

352 353
  def reportUsage(self, computer_usage):
    if computer_usage == "":
Łukasz Nowak's avatar
Łukasz Nowak committed
354
      return
355
    self._connection_helper.POST('useComputer', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
356
      'computer_id': self._computer_id,
357
      'use_string': computer_usage})
Łukasz Nowak's avatar
Łukasz Nowak committed
358 359

  def updateConfiguration(self, xml):
360
    return self._connection_helper.POST('loadComputerConfigurationFromXML', data={'xml': xml})
Łukasz Nowak's avatar
Łukasz Nowak committed
361

Łukasz Nowak's avatar
Łukasz Nowak committed
362
  def bang(self, message):
363
    self._connection_helper.POST('computerBang', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
364 365 366
      'computer_id': self._computer_id,
      'message': message})

367
  def getStatus(self):
368
    xml = self._connection_helper.GET('getComputerStatus', params={'computer_id': self._computer_id})
369
    return xml_marshaller.loads(xml)
370

371
  def revokeCertificate(self):
372
    self._connection_helper.POST('revokeComputerCertificate', data={
373 374 375
      'computer_id': self._computer_id})

  def generateCertificate(self):
376
    xml = self._connection_helper.POST('generateComputerCertificate', data={
377 378 379
      'computer_id': self._computer_id})
    return xml_marshaller.loads(xml)

380

381 382 383 384 385 386 387 388 389 390 391 392 393
def parsed_error_message(status, body, path):
  m = re.search('(Error Value:\n.*)', body, re.MULTILINE)
  if m:
    match = ' '.join(line.strip() for line in m.group(0).split('\n'))
    return '%s (status %s while calling %s)' % (
                saxutils.unescape(match),
                status,
                path
            )
  else:
    return 'Server responded with wrong code %s with %s' % (status, path)


Cédric de Saint Martin's avatar
Cédric de Saint Martin committed
394
class ComputerPartition(SlapRequester):
Łukasz Nowak's avatar
Łukasz Nowak committed
395 396
  zope.interface.implements(interface.IComputerPartition)

Marco Mariani's avatar
Marco Mariani committed
397
  def __init__(self, computer_id=None, partition_id=None,
398 399 400
               request_dict=None, connection_helper=None,
               hateoas_navigator=None):
    SlapDocument.__init__(self, connection_helper, hateoas_navigator)
401 402 403 404 405 406 407
    if request_dict is not None and (computer_id is not None or
        partition_id is not None):
      raise TypeError('request_dict conflicts with computer_id and '
        'partition_id')
    if request_dict is None and (computer_id is None or partition_id is None):
      raise TypeError('computer_id and partition_id or request_dict are '
        'required')
Łukasz Nowak's avatar
Łukasz Nowak committed
408 409
    self._computer_id = computer_id
    self._partition_id = partition_id
410
    self._request_dict = request_dict
Łukasz Nowak's avatar
Łukasz Nowak committed
411

412 413 414
    # Just create an empty file (for nothing requested yet)
    self._updateTransactionFile(partition_reference=None)

Łukasz Nowak's avatar
Łukasz Nowak committed
415 416 417
  def __getinitargs__(self):
    return (self._computer_id, self._partition_id, )

418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443
  def _updateTransactionFile(self, partition_reference=None):
    """
    Store reference to all Instances requested by this Computer Parition
    """
    # Environ variable set by Slapgrid while processing this partition
    instance_root = os.environ.get('SLAPGRID_INSTANCE_ROOT', '')
    if not instance_root or not self._partition_id:
      return

    transaction_file_name = COMPUTER_PARTITION_REQUEST_LIST_TEMPLATE_FILENAME % self._partition_id
    transaction_file_path = os.path.join(instance_root, self._partition_id,
                                        transaction_file_name)

    try:
      if partition_reference is None:
        if os.access(os.path.join(instance_root, self._partition_id), os.W_OK):
          if os.path.exists(transaction_file_path):
            return
          transac_file = open(transaction_file_path, 'w')
          transac_file.close()
      else:
        with open(transaction_file_path, 'a') as transac_file:
          transac_file.write('%s\n' % partition_reference)
    except OSError:
      return

Łukasz Nowak's avatar
Łukasz Nowak committed
444
  def request(self, software_release, software_type, partition_reference,
445 446
              shared=False, partition_parameter_kw=None, filter_kw=None,
              state=None):
Łukasz Nowak's avatar
Łukasz Nowak committed
447 448 449
    if partition_parameter_kw is None:
      partition_parameter_kw = {}
    elif not isinstance(partition_parameter_kw, dict):
Marco Mariani's avatar
Marco Mariani committed
450
      raise ValueError("Unexpected type of partition_parameter_kw '%s'" %
Łukasz Nowak's avatar
Łukasz Nowak committed
451 452 453 454 455
                       partition_parameter_kw)

    if filter_kw is None:
      filter_kw = {}
    elif not isinstance(filter_kw, dict):
Marco Mariani's avatar
Marco Mariani committed
456
      raise ValueError("Unexpected type of filter_kw '%s'" %
Łukasz Nowak's avatar
Łukasz Nowak committed
457 458
                       filter_kw)

459 460 461 462
    # Let enforce a default software type
    if software_type is None:
      software_type = DEFAULT_SOFTWARE_TYPE

463 464
    request_dict = {
        'computer_id': self._computer_id,
Łukasz Nowak's avatar
Łukasz Nowak committed
465 466 467 468 469 470 471 472
        'computer_partition_id': self._partition_id,
        'software_release': software_release,
        'software_type': software_type,
        'partition_reference': partition_reference,
        'shared_xml': xml_marshaller.dumps(shared),
        'partition_parameter_xml': xml_marshaller.dumps(
                                        partition_parameter_kw),
        'filter_xml': xml_marshaller.dumps(filter_kw),
473
        'state': xml_marshaller.dumps(state),
474
    }
475
    self._updateTransactionFile(partition_reference)
476
    return self._requestComputerPartition(request_dict)
Łukasz Nowak's avatar
Łukasz Nowak committed
477 478

  def building(self):
479
    self._connection_helper.POST('buildingComputerPartition', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
480
      'computer_id': self._computer_id,
481
      'computer_partition_id': self.getId()})
Łukasz Nowak's avatar
Łukasz Nowak committed
482 483

  def available(self):
484
    self._connection_helper.POST('availableComputerPartition', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
485
      'computer_id': self._computer_id,
486
      'computer_partition_id': self.getId()})
Łukasz Nowak's avatar
Łukasz Nowak committed
487 488

  def destroyed(self):
489
    self._connection_helper.POST('destroyedComputerPartition', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
490
      'computer_id': self._computer_id,
491
      'computer_partition_id': self.getId(),
Łukasz Nowak's avatar
Łukasz Nowak committed
492 493 494
      })

  def started(self):
495
    self._connection_helper.POST('startedComputerPartition', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
496
      'computer_id': self._computer_id,
497
      'computer_partition_id': self.getId(),
Łukasz Nowak's avatar
Łukasz Nowak committed
498 499 500
      })

  def stopped(self):
501
    self._connection_helper.POST('stoppedComputerPartition', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
502
      'computer_id': self._computer_id,
503
      'computer_partition_id': self.getId(),
Łukasz Nowak's avatar
Łukasz Nowak committed
504 505
      })

506
  def error(self, error_log, logger=None):
507
    try:
508
      self._connection_helper.POST('softwareInstanceError', data={
509 510 511 512
        'computer_id': self._computer_id,
        'computer_partition_id': self.getId(),
        'error_log': error_log})
    except Exception:
513
      (logger or fallback_logger).exception('')
Łukasz Nowak's avatar
Łukasz Nowak committed
514

Łukasz Nowak's avatar
Łukasz Nowak committed
515
  def bang(self, message):
516
    self._connection_helper.POST('softwareInstanceBang', data={
Łukasz Nowak's avatar
Łukasz Nowak committed
517
      'computer_id': self._computer_id,
518
      'computer_partition_id': self.getId(),
Łukasz Nowak's avatar
Łukasz Nowak committed
519 520
      'message': message})

521
  def rename(self, new_name, slave_reference=None):
522 523 524 525 526 527 528
    post_dict = {
            'computer_id': self._computer_id,
            'computer_partition_id': self.getId(),
            'new_name': new_name,
            }
    if slave_reference:
      post_dict['slave_reference'] = slave_reference
529
    self._connection_helper.POST('softwareInstanceRename', data=post_dict)
530

531 532 533 534 535 536
  def getInformation(self, partition_reference):
    """
    Return all needed informations about an existing Computer Partition
    in the Instance tree of the current Computer Partition.
    """
    if not getattr(self, '_hateoas_navigator', None):
537
      raise Exception('SlapOS Master Hateoas API required for this operation is not availble.')
538 539 540 541 542 543 544 545 546 547 548

    raw_information = self._hateoas_navigator.getRelatedInstanceInformation(partition_reference)
    software_instance = SoftwareInstance()
    # XXX redefine SoftwareInstance to be more consistent
    for key, value in raw_information.iteritems():
      if key in ['_links']:
        continue
      setattr(software_instance, '_%s' % key, value)
    setattr(software_instance, '_software_release_url', raw_information['_links']['software_release'])
    return software_instance

549

Łukasz Nowak's avatar
Łukasz Nowak committed
550
  def getId(self):
Cédric de Saint Martin's avatar
Cédric de Saint Martin committed
551
    if not getattr(self, '_partition_id', None):
552
      raise ResourceNotReady()
Łukasz Nowak's avatar
Łukasz Nowak committed
553 554
    return self._partition_id

555
  def getInstanceGuid(self):
556
    """Return instance_guid. Raise ResourceNotReady if it doesn't exist."""
557 558 559 560
    if not getattr(self, '_instance_guid', None):
      raise ResourceNotReady()
    return self._instance_guid

Łukasz Nowak's avatar
Łukasz Nowak committed
561
  def getState(self):
562
    """return _requested_state. Raise ResourceNotReady if it doesn't exist."""
563 564
    if not getattr(self, '_requested_state', None):
      raise ResourceNotReady()
Łukasz Nowak's avatar
Łukasz Nowak committed
565 566
    return self._requested_state

567 568 569 570 571 572 573 574 575 576 577 578
  def getType(self):
    """
    return the Software Type of the instance.
    Raise RessourceNotReady if not present.
    """
    # XXX: software type should not belong to the parameter dict.
    software_type = self.getInstanceParameterDict().get(
        'slap_software_type', None)
    if not software_type:
      raise ResourceNotReady()
    return software_type

Łukasz Nowak's avatar
Łukasz Nowak committed
579 580 581
  def getInstanceParameterDict(self):
    return getattr(self, '_parameter_dict', None) or {}

582
  def getConnectionParameterDict(self):
583 584 585
    connection_dict = getattr(self, '_connection_dict', None)
    if connection_dict is None:
      # XXX Backward compatibility for older slapproxy (<= 1.0.0)
586
      connection_dict = xml2dict(getattr(self, 'connection_xml', ''))
587

588
    return connection_dict or {}
589

Łukasz Nowak's avatar
Łukasz Nowak committed
590 591 592 593
  def getSoftwareRelease(self):
    """
    Returns the software release associate to the computer partition.
    """
594
    if not getattr(self, '_software_release_document', None):
Łukasz Nowak's avatar
Łukasz Nowak committed
595 596 597 598 599
      raise NotFoundError("No software release information for partition %s" %
          self.getId())
    else:
      return self._software_release_document

600
  def setConnectionDict(self, connection_dict, slave_reference=None):
601 602 603
    if self.getConnectionParameterDict() == connection_dict:
      return

604
    if slave_reference is not None:
605
      # check the connection parameters from the slave
606

607 608 609
      # Should we check existence?
      slave_parameter_list = self.getInstanceParameter("slave_instance_list")
      slave_connection_dict = {}
610
      connection_parameter_hash = None
611 612 613
      for slave_parameter_dict in slave_parameter_list:
        if slave_parameter_dict.get("slave_reference") == slave_reference:
          connection_parameter_hash = slave_parameter_dict.get("connection-parameter-hash", None)
614 615 616
          break

      # Skip as nothing changed for the slave
617 618
      if connection_parameter_hash is not None and \
        connection_parameter_hash == hashlib.sha256(str(connection_dict)).hexdigest():
619
        return
620

621
    self._connection_helper.POST('setComputerPartitionConnectionXml', data={
622 623 624 625
          'computer_id': self._computer_id,
          'computer_partition_id': self._partition_id,
          'connection_xml': xml_marshaller.dumps(connection_dict),
          'slave_reference': slave_reference})
Łukasz Nowak's avatar
Łukasz Nowak committed
626

627 628 629 630 631 632 633
  def getInstanceParameter(self, key):
    parameter_dict = getattr(self, '_parameter_dict', None) or {}
    if key in parameter_dict:
      return parameter_dict[key]
    else:
      raise NotFoundError("%s not found" % key)

Łukasz Nowak's avatar
Łukasz Nowak committed
634
  def getConnectionParameter(self, key):
635
    connection_dict = self.getConnectionParameterDict()
Łukasz Nowak's avatar
Łukasz Nowak committed
636 637 638 639 640 641 642 643 644 645
    if key in connection_dict:
      return connection_dict[key]
    else:
      raise NotFoundError("%s not found" % key)

  def setUsage(self, usage_log):
    # XXX: this implementation has not been reviewed
    self.usage = usage_log

  def getCertificate(self):
646
    xml = self._connection_helper.GET('getComputerPartitionCertificate',
647
            params={
648 649 650 651
                'computer_id': self._computer_id,
                'computer_partition_id': self._partition_id,
                }
            )
652
    return xml_marshaller.loads(xml)
653 654

  def getStatus(self):
655
    xml = self._connection_helper.GET('getComputerPartitionStatus',
656
            params={
657 658 659 660
                'computer_id': self._computer_id,
                'computer_partition_id': self._partition_id,
                }
            )
661
    return xml_marshaller.loads(xml)
662 663 664 665 666 667 668 669 670
  
  def getFullHostingIpAddressList(self):
    xml = self._connection_helper.GET('getHostingSubscriptionIpList',
            params={
                'computer_id': self._computer_id,
                'computer_partition_id': self._partition_id,
                }
            )
    return xml_marshaller.loads(xml)
Łukasz Nowak's avatar
Łukasz Nowak committed
671

672 673 674 675 676 677 678 679 680
  def setComputerPartitionRelatedInstanceList(self, instance_reference_list):
    self._connection_helper.POST('updateComputerPartitionRelatedInstanceList',
        data={
          'computer_id': self._computer_id,
          'computer_partition_id': self._partition_id,
          'instance_reference_xml': xml_marshaller.dumps(instance_reference_list)
          }
        )

681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700
def _addIpv6Brackets(url):
  # if master_url contains an ipv6 without bracket, add it
  # Note that this is mostly to limit specific issues with
  # backward compatiblity, not to ensure generic detection.
  api_scheme, api_netloc, api_path, api_query, api_fragment = urlparse.urlsplit(url)
  try:
    ip = netaddr.IPAddress(api_netloc)
    port = None
  except netaddr.AddrFormatError:
    try:
      ip = netaddr.IPAddress(':'.join(api_netloc.split(':')[:-1]))
      port = api_netloc.split(':')[-1]
    except netaddr.AddrFormatError:
      ip = port = None
  if ip and ip.version == 6:
    api_netloc = '[%s]' % ip
    if port:
      api_netloc = '%s:%s' % (api_netloc, port)
    url = urlparse.urlunsplit((api_scheme, api_netloc, api_path, api_query, api_fragment))
  return url
Marco Mariani's avatar
Marco Mariani committed
701

Łukasz Nowak's avatar
Łukasz Nowak committed
702
class ConnectionHelper:
703
  def __init__(self, master_url, key_file=None,
Marco Mariani's avatar
Marco Mariani committed
704
               cert_file=None, master_ca_file=None, timeout=None):
705
    master_url = _addIpv6Brackets(master_url)
706 707 708 709 710
    if master_url.endswith('/'):
        self.slapgrid_uri = master_url
    else:
        # add a slash or the last path segment will be ignored by urljoin
        self.slapgrid_uri = master_url + '/'
Łukasz Nowak's avatar
Łukasz Nowak committed
711 712 713
    self.key_file = key_file
    self.cert_file = cert_file
    self.master_ca_file = master_ca_file
714
    self.timeout = timeout
Łukasz Nowak's avatar
Łukasz Nowak committed
715 716

  def getComputerInformation(self, computer_id):
717
    xml = self.GET('getComputerInformation', params={'computer_id': computer_id})
718
    return xml_marshaller.loads(xml)
Łukasz Nowak's avatar
Łukasz Nowak committed
719

720
  def getFullComputerInformation(self, computer_id):
721 722 723 724
    """
    Retrieve from SlapOS Master Computer instance containing all needed
    informations (Software Releases, Computer Partitions, ...).
    """
725 726
    path = 'getFullComputerInformation'
    params = {'computer_id': computer_id}
727 728
    if not computer_id:
      # XXX-Cedric: should raise something smarter than "NotFound".
729
      raise NotFoundError('%r %r' % (path, params))
730
    try:
731
      xml = self.GET(path, params=params)
732 733 734
    except NotFoundError:
      # XXX: This is a ugly way to keep backward compatibility,
      # We should stablise slap library soon.
735
      xml = self.GET('getComputerInformation', params=params)
736

737 738 739
    if type(xml) is unicode:
      xml = str(xml)
      xml.encode('utf-8')
740
    return xml_marshaller.loads(xml)
741

742 743
  def do_request(self, method, path, params=None, data=None, headers=None):
    url = urlparse.urljoin(self.slapgrid_uri, path)
744 745 746
    if headers is None:
      headers = {}
    headers.setdefault('Accept', '*/*')
747
    if path.startswith('/'):
748 749
      path = path[1:]
#      raise ValueError('method path should be relative: %s' % path)
750

Łukasz Nowak's avatar
Łukasz Nowak committed
751
    try:
752 753 754 755 756 757 758
      if url.startswith('https'):
        cert = (self.cert_file, self.key_file)
      else:
        cert = None

      # XXX TODO: handle host cert verify

759 760 761 762 763 764 765 766
      # Old behavior was to pass empty parameters as "None" value.
      # Behavior kept for compatibility with old slapproxies (< v1.3.3).
      # Can be removed when old slapproxies are no longer in use.
      if data:
        for k, v in data.iteritems():
          if v is None:
            data[k] = 'None'

767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788
      req = method(url=url,
                   params=params,
                   cert=cert,
                   verify=False,
                   data=data,
                   headers=headers,
                   timeout=self.timeout)
      req.raise_for_status()

    except (requests.Timeout, requests.ConnectionError) as exc:
      raise ConnectionError("Couldn't connect to the server. Please "
                            "double check given master-url argument, and make sure that IPv6 is "
                            "enabled on your machine and that the server is available. The "
                            "original error was:\n%s" % exc)
    except requests.HTTPError as exc:
      if exc.response.status_code == requests.status_codes.codes.not_found:
        msg = url
        if params:
            msg += ' - %s' % params
        raise NotFoundError(msg)
      elif exc.response.status_code == requests.status_codes.codes.request_timeout:
        # this is explicitly returned by SlapOS master, and does not really mean timeout
789
        raise ResourceNotReady(path)
790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806
        # XXX TODO test request timeout and resource not found
      else:
        # we don't know how or don't want to handle these (including Unauthorized)
        req.raise_for_status()
    except requests.exceptions.SSLError as exc:
      raise AuthenticationError("%s\nCouldn't authenticate computer. Please "
                                "check that certificate and key exist and are valid." % exc)

#    XXX TODO parse server messages for client configure and node register
#    elif response.status != httplib.OK:
#      message = parsed_error_message(response.status,
#                                     response.read(),
#                                     path)
#      raise ServerError(message)

    return req

807
  def GET(self, path, params=None, headers=None):
808 809
    req = self.do_request(requests.get,
                          path=path,
810 811
                          params=params,
                          headers=headers)
812
    return req.text.encode('utf-8')
813 814

  def POST(self, path, params=None, data=None,
Marco Mariani's avatar
Marco Mariani committed
815
           content_type='application/x-www-form-urlencoded'):
816 817 818 819 820
    req = self.do_request(requests.post,
                          path=path,
                          params=params,
                          data=data,
                          headers={'Content-type': content_type})
821
    return req.text.encode('utf-8')
822 823


Łukasz Nowak's avatar
Łukasz Nowak committed
824 825 826
class slap:
  zope.interface.implements(interface.slap)

Alain Takoudjou's avatar
Alain Takoudjou committed
827
  def initializeConnection(self, slapgrid_uri,
828 829 830 831
                           key_file=None, cert_file=None,
                           master_ca_file=None,
                           timeout=60,
                           slapgrid_rest_uri=None):
832 833 834 835
    if master_ca_file:
      raise NotImplementedError('Master certificate not verified in this version: %s' % master_ca_file)

    self._connection_helper = ConnectionHelper(slapgrid_uri, key_file, cert_file, master_ca_file, timeout)
Łukasz Nowak's avatar
Łukasz Nowak committed
836

837 838 839 840 841
    if not slapgrid_rest_uri:
      try:
        slapgrid_rest_uri = self._connection_helper.GET('getHateoasUrl')
      except:
        pass
842
    if slapgrid_rest_uri:
843
      self._hateoas_navigator = SlapHateoasNavigator(
844 845 846 847 848 849 850
          slapgrid_rest_uri,
          key_file, cert_file,
          master_ca_file, timeout
      )
    else:
      self._hateoas_navigator = None

851
  # XXX-Cedric: this method is never used and thus should be removed.
Łukasz Nowak's avatar
Łukasz Nowak committed
852 853 854 855 856
  def registerSoftwareRelease(self, software_release):
    """
    Registers connected representation of software release and
    returns SoftwareRelease class object
    """
857
    return SoftwareRelease(software_release=software_release,
858 859
      connection_helper=self._connection_helper,
      hateoas_navigator=self._hateoas_navigator
860
    )
Łukasz Nowak's avatar
Łukasz Nowak committed
861 862 863 864 865 866

  def registerComputer(self, computer_guid):
    """
    Registers connected representation of computer and
    returns Computer class object
    """
867 868 869 870
    return Computer(computer_guid,
      connection_helper=self._connection_helper,
      hateoas_navigator=self._hateoas_navigator
    )
Łukasz Nowak's avatar
Łukasz Nowak committed
871 872 873 874 875 876

  def registerComputerPartition(self, computer_guid, partition_id):
    """
    Registers connected representation of computer partition and
    returns Computer Partition class object
    """
877 878 879 880
    if not computer_guid or not partition_id:
      # XXX-Cedric: should raise something smarter than NotFound
      raise NotFoundError

881
    xml = self._connection_helper.GET('registerComputerPartition',
882
            params = {
883 884 885 886
                'computer_reference': computer_guid,
                'computer_partition_reference': partition_id,
                }
            )
887 888 889
    if type(xml) is unicode:
      xml = str(xml)
      xml.encode('utf-8')
890
    result = xml_marshaller.loads(xml)
891 892 893
    # XXX: dirty hack to make computer partition usable. xml_marshaller is too
    # low-level for our needs here.
    result._connection_helper = self._connection_helper
894
    result._hateoas_navigator = self._hateoas_navigator
895
    return result
Łukasz Nowak's avatar
Łukasz Nowak committed
896 897

  def registerOpenOrder(self):
898 899 900 901
    return OpenOrder(
      connection_helper=self._connection_helper,
      hateoas_navigator=self._hateoas_navigator
  )
Łukasz Nowak's avatar
Łukasz Nowak committed
902 903

  def registerSupply(self):
904 905 906 907
    return Supply(
      connection_helper=self._connection_helper,
      hateoas_navigator=self._hateoas_navigator
  )
908

909 910
  def getSoftwareReleaseListFromSoftwareProduct(self,
      software_product_reference=None, software_release_url=None):
911 912
    url = 'getSoftwareReleaseListFromSoftwareProduct'
    params = {}
913
    if software_product_reference:
914 915 916
      if software_release_url is not None:
        raise AttributeError('Both software_product_reference and '
                             'software_release_url parameters are specified.')
917
      params['software_product_reference'] = software_product_reference
918
    else:
919 920 921
      if software_release_url is None:
        raise AttributeError('None of software_product_reference and '
                             'software_release_url parameters are specified.')
922
      params['software_release_url'] = software_release_url
923

924 925 926 927 928
    xml = self._connection_helper.GET(url, params=params)
    if type(xml) is unicode:
      xml = str(xml)
      xml.encode('utf-8')
    result = xml_marshaller.loads(xml)
929 930
    assert(type(result) == list)
    return result
931 932 933

  def getOpenOrderDict(self):
    if not getattr(self, '_hateoas_navigator', None):
934
      raise Exception('SlapOS Master Hateoas API required for this operation is not availble.')
935 936 937
    return self._hateoas_navigator.getHostingSubscriptionDict()

class HateoasNavigator(object):
938 939 940 941 942 943 944
  """
  Navigator for HATEOAS-style APIs.
  Inspired by
  https://git.erp5.org/gitweb/jio.git/blob/HEAD:/src/jio.storage/erp5storage.js
  """
  # XXX: needs to be designed for real. For now, just a non-maintainable prototype.
  # XXX: export to a standalone library, independant from slap.
945 946 947 948 949 950 951 952 953
  def __init__(self, slapgrid_uri,
               key_file=None, cert_file=None,
               master_ca_file=None, timeout=60):
    self.slapos_master_hateoas_uri = slapgrid_uri
    self.key_file = key_file
    self.cert_file = cert_file
    self.master_ca_file = master_ca_file
    self.timeout = timeout

954
  def GET(self, uri, headers=None):
955 956
    connection_helper = ConnectionHelper(
        uri, self.key_file, self.cert_file, self.master_ca_file, self.timeout)
957
    return connection_helper.GET(uri, headers=headers)
958

959 960 961 962 963 964 965 966 967 968
  def hateoasGetLinkFromLinks(self, links, title):
    if type(links) == dict:
      if links.get('title') == title:
        return links['href']
      raise NotFoundError('Action %s not found.' % title)
    for action in links:
      if action.get('title') == title:
        return action['href']
    else:
      raise NotFoundError('Action %s not found.' % title)
969

970 971 972 973 974 975 976
  def getRelativeUrlFromUrn(self, urn):
    urn_schema = 'urn:jio:get:'
    try:
      _, url = urn.split(urn_schema)
    except ValueError:
      return
    return str(url)
977

978 979
  def getSiteDocument(self, url, headers=None):
    result = self.GET(url, headers)
980 981
    return json.loads(result)

982 983 984 985 986
  def getRootDocument(self):
    # XXX what about cache?
    cached_root_document = getattr(self, 'root_document', None)
    if cached_root_document:
      return cached_root_document
987 988 989 990
    self.root_document = self.getSiteDocument(
        self.slapos_master_hateoas_uri,
        headers={'Cache-Control': 'no-cache'}
    )
991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006
    return self.root_document

  def getDocumentAndHateoas(self, relative_url, view='view'):
    site_document = self.getRootDocument()
    return expand(
        site_document['_links']['traverse']['href'],
        dict(relative_url=relative_url, view=view)
    )

  def getMeDocument(self):
    person_relative_url = self.getRelativeUrlFromUrn(
        self.getRootDocument()['_links']['me']['href'])
    person_url = self.getDocumentAndHateoas(person_relative_url)
    return json.loads(self.GET(person_url))

class SlapHateoasNavigator(HateoasNavigator):
1007
  def _hateoas_getHostingSubscriptionDict(self):
1008
    action_object_slap_list = self.getMeDocument()['_links']['action_object_slap']
1009 1010 1011 1012 1013 1014 1015 1016 1017
    for action in action_object_slap_list:
      if action.get('title') == 'getHateoasHostingSubscriptionList':
        getter_link = action['href']
        break
    else:
      raise Exception('Hosting subscription not found.')
    result = self.GET(getter_link)
    return json.loads(result)['_links']['content']

1018 1019
  # XXX rename me to blablaUrl(self)
  def _hateoas_getRelatedHostingSubscription(self):
1020
    action_object_slap_list = self.getMeDocument()['_links']['action_object_slap']
1021 1022 1023 1024
    getter_link = self.hateoasGetLinkFromLinks(action_object_slap_list, 'getHateoasRelatedHostingSubscription')
    result = self.GET(getter_link)
    return json.loads(result)['_links']['action_object_jump']['href']

1025 1026 1027
  def _hateoasGetInformation(self, url):
    result = self.GET(url)
    result = json.loads(result)
1028
    object_link = self.hateoasGetLinkFromLinks(
1029 1030 1031 1032 1033 1034
      result['_links']['action_object_slap'],
      'getHateoasInformation'
    )
    result = self.GET(object_link)
    return json.loads(result)

1035 1036 1037 1038 1039 1040
  def getHateoasInstanceList(self, hosting_subscription_url):
    hosting_subscription = json.loads(self.GET(hosting_subscription_url))
    instance_list_url = self.hateoasGetLinkFromLinks(hosting_subscription['_links']['action_object_slap'], 'getHateoasInstanceList')
    instance_list = json.loads(self.GET(instance_list_url))
    return instance_list['_links']['content']

1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067
  def getHostingSubscriptionDict(self):
    hosting_subscription_link_list = self._hateoas_getHostingSubscriptionDict()
    hosting_subscription_dict = {}
    for hosting_subscription_link in hosting_subscription_link_list:
      raw_information = self.getHostingSubscriptionRootSoftwareInstanceInformation(hosting_subscription_link['title'])
      software_instance = SoftwareInstance()
      # XXX redefine SoftwareInstance to be more consistent
      for key, value in raw_information.iteritems():
        if key in ['_links']:
          continue
        setattr(software_instance, '_%s' % key, value)
      setattr(software_instance, '_software_release_url', raw_information['_links']['software_release'])
      hosting_subscription_dict[software_instance._title] = software_instance

    return hosting_subscription_dict

  def getHostingSubscriptionRootSoftwareInstanceInformation(self, reference):
    hosting_subscription_list = self._hateoas_getHostingSubscriptionDict()
    for hosting_subscription in hosting_subscription_list:
      if hosting_subscription.get('title') == reference:
        hosting_subscription_url = hosting_subscription['href']
        break
    else:
      raise NotFoundError('This document does not exist.')

    hosting_subscription = json.loads(self.GET(hosting_subscription_url))

1068
    software_instance_url = self.hateoasGetLinkFromLinks(
1069 1070 1071 1072 1073 1074 1075
        hosting_subscription['_links']['action_object_slap'],
        'getHateoasRootInstance'
    )
    response = self.GET(software_instance_url)
    response = json.loads(response)
    software_instance_url = response['_links']['content'][0]['href']
    return self._hateoasGetInformation(software_instance_url)
1076 1077 1078 1079 1080 1081 1082

  def getRelatedInstanceInformation(self, reference):
    related_hosting_subscription_url = self._hateoas_getRelatedHostingSubscription()
    instance_list = self.getHateoasInstanceList(related_hosting_subscription_url)
    instance_url = self.hateoasGetLinkFromLinks(instance_list, reference)
    instance = self._hateoasGetInformation(instance_url)
    return instance