Add base HNV model

This commit is contained in:
Alexandru Coman 2017-01-30 22:03:38 +02:00
parent 6d38fd322a
commit acf173d3ea
No known key found for this signature in database
GPG Key ID: A7B6A9021F704507
3 changed files with 416 additions and 25 deletions

189
hnv_client/client.py Normal file
View File

@ -0,0 +1,189 @@
# Copyright 2017 Cloudbase Solutions Srl
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""This module contains all the available HNV resources."""
import time
import uuid
from oslo_log import log as logging
from hnv_client.common import constant
from hnv_client.common import exception
from hnv_client.common import model
from hnv_client.common import utils
from hnv_client import config as hnv_config
LOG = logging.getLogger(__name__)
CONFIG = hnv_config.CONFIG
class _BaseHNVModel(model.Model):
_endpoint = CONFIG.HNV.url
resource_ref = model.Field(name="resource_ref", key="resourceRef",
is_property=False)
"""A relative URI to an associated resource."""
resource_id = model.Field(name="resource_id", key="resourceId",
is_property=False,
default=lambda: str(uuid.uuid1()))
"""The resource ID for the resource. The value MUST be unique in
the context of the resource if it is a top-level resource, or in the
context of the direct parent resource if it is a child resource."""
instance_id = model.Field(name="instance_id", key="instanceId",
is_property=False)
"""The globally unique Id generated and used internally by the Network
Controller. The mapping resource that enables the client to map between
the instanceId and the resourceId."""
etag = model.Field(name="etag", key="etag", is_property=False)
"""An opaque string representing the state of the resource at the
time the response was generated."""
tags = model.Field(name="tags", key="tags", is_property=False,
is_required=False)
provisioning_state = model.Field(name="provisioning_state",
key="provisioningState",
is_read_only=True, is_required=False)
"""Indicates the various states of the resource. Valid values are
Deleting, Failed, Succeeded, and Updating."""
def __init__(self, **fields):
self._parent_id = fields.pop("parent_id", None)
super(_BaseHNVModel, self).__init__(**fields)
@staticmethod
def _get_client():
"""Create a new client for the HNV REST API."""
return utils.get_client(url=CONFIG.HNV.url,
username=CONFIG.HNV.username,
password=CONFIG.HNV.password,
allow_insecure=CONFIG.HNV.https_allow_insecure,
ca_bundle=CONFIG.HNV.https_ca_bundle)
@property
def parent_id(self):
"""The identifier for the specific ancestor resource."""
return self._parent_id
@classmethod
def get(cls, resource_id=None, parent_id=None):
"""Retrieves the required resources.
:param resource_id: The identifier for the specific resource
within the resource type.
:param parent_id: The identifier for the specific ancestor
resource within the resource type.
"""
client = cls._get_client()
endpoint = cls._endpoint.format(resource_id=resource_id or "",
parent_id=parent_id or "")
raw_data = client.get_resource(endpoint)
if resource_id is None:
return [cls.from_raw_data(item) for item in raw_data["value"]]
else:
return cls.from_raw_data(raw_data)
@classmethod
def remove(cls, resource_id, parent_id=None, wait=True, timeout=None):
"""Delete the required resource.
:param resource_id: The identifier for the specific resource
within the resource type.
:param parent_id: The identifier for the specific ancestor
resource within the resource type.
:param wait: Whether to wait until the operation is completed
:param timeout: The maximum amount of time required for this
operation to be completed.
If optional :param wait: is True and timeout is None (the default),
block if necessary until the resource is available. If timeout is a
positive number, it blocks at most timeout seconds and raises the
`TimeOut` exception if no item was available within that time.
Otherwise (block is false), return a resource if one is immediately
available, else raise the `NotFound` exception (timeout is ignored
in that case).
"""
client = cls._get_client()
endpoint = cls._endpoint.format(resource_id=resource_id or "",
parent_id=parent_id or "")
client.remove_resource(endpoint)
elapsed_time = 0
while wait:
try:
client.get_resource(endpoint)
except exception.NotFound:
break
elapsed_time += CONFIG.HNV.retry_interval
if timeout and elapsed_time > timeout:
raise exception.TimeOut("The request timed out.")
time.sleep(CONFIG.HNV.retry_interval)
def commit(self, wait=True, timeout=None):
"""Apply all the changes on the current model.
:param wait: Whether to wait until the operation is completed
:param timeout: The maximum amount of time required for this
operation to be completed.
If optional :param wait: is True and timeout is None (the default),
block if necessary until the resource is available. If timeout is a
positive number, it blocks at most timeout seconds and raises the
`TimeOut` exception if no item was available within that time.
Otherwise (block is false), return a resource if one is immediately
available, else raise the `NotFound` exception (timeout is ignored
in that case).
"""
super(_BaseHNVModel, self).commit(wait=wait, timeout=timeout)
client = self._get_client()
endpoint = self._endpoint.format(resource_id=self.resource_id or "",
parent_id=self.parent_id or "")
request_body = self.dump(include_read_only=False)
response = client.update_resource(endpoint, data=request_body)
elapsed_time = 0
while wait:
response = client.get_resource(endpoint)
properties = response.get("properties", {})
provisioning_state = properties.get("provisioningState", None)
if not provisioning_state:
raise exception.ServiceException("The object doesn't contain "
"`provisioningState`.")
if provisioning_state == constant.FAILED:
raise exception.ServiceException(
"Failed to complete the required operation.")
elif provisioning_state == constant.SUCCEEDED:
break
elapsed_time += CONFIG.HNV.retry_interval
if timeout and elapsed_time > timeout:
raise exception.TimeOut("The request timed out.")
time.sleep(CONFIG.HNV.retry_interval)
# Process the raw data from the update response
fields = self.process_raw_data(response)
# Set back the provision flag
self._provision_done = False
# Update the current model representation
self._set_fields(fields)
# Lock the current model
self._provision_done = True

View File

@ -79,13 +79,14 @@ class Field(object):
"""
def __init__(self, name, key, default=None, is_required=False,
is_property=True, is_read_only=False):
is_property=True, is_read_only=False, is_static=False):
self._name = name
self._key = key
self._default = default
self._is_required = is_required
self._is_property = is_property
self._is_read_only = is_read_only
self._is_static = is_static
@property
def name(self):
@ -117,6 +118,11 @@ class Field(object):
"""Whether the current field can be updated."""
return self._is_read_only
@property
def is_static(self):
"""Whether the value of the current field can be changed."""
return self._is_static
def add_to_class(self, model_class):
"""Replace the `Field` attribute with a named `_FieldDescriptor`.
@ -164,15 +170,15 @@ class _ModelOptions(object):
field = self._fields.pop(field_name, None)
if field is not None and field.default is not None:
if six.callable(field.default):
self._default_callables.pop(field.name, None)
self._default_callables.pop(field.key, None)
else:
self._defaults.pop(field.name, None)
self._defaults.pop(field.key, None)
def get_defaults(self):
"""Get a dictionary that contains all the available defaults."""
defaults = self._defaults.copy()
for field_name, default in self._default_callables.items():
defaults[field_name] = default()
for field_key, default in self._default_callables.items():
defaults[field_key] = default()
return defaults
@ -197,7 +203,7 @@ class _BaseModel(type):
# Get all the available fields for the current model.
for name, field in list(cls.__dict__.items()):
if isinstance(field, Field) and not name.startswith("_"):
if not name.startswith("_") and isinstance(field, Field):
field.add_to_class(cls)
# Create string representation for the current model before finalizing
@ -213,16 +219,9 @@ class Model(object):
def __init__(self, **fields):
self._data = self._meta.get_defaults()
self._changes = {}
self._provision_done = False
for field in self._meta.fields.values():
value = fields.pop(field.name, None)
if field.key not in self._data or value:
setattr(self, field.name, value)
if fields:
LOG.debug("Unrecognized fields: %r", fields)
self._set_fields(fields)
self._provision_done = True
def __eq__(self, other):
@ -231,14 +230,46 @@ class Model(object):
my_properties = self.dump().get("properties", {})
other_properties = other.dump().get("properties", {})
for properties in (my_properties, other_properties):
for ignore_key in ("provisioningState", ):
properties.pop(ignore_key, None)
return my_properties == other_properties
def __ne__(self, other):
return not self.__eq__(other)
def _unpack(self, value):
"""Obtain the raw representation of the received object."""
if isinstance(value, Model):
return value.dump()
if isinstance(value, list):
container_list = []
for item in value:
container_list.append(self._unpack(item))
return container_list
if isinstance(value, dict):
container_dict = {}
for key, item in value.items():
container_dict[key] = self._unpack(item)
return container_dict
return value
def _set_fields(self, fields):
"""Set or update the fields value."""
for field in self._meta.fields.values():
value = fields.pop(field.name, None)
if field.key not in self._data or value:
setattr(self, field.name, value)
if fields:
LOG.debug("Unrecognized fields: %r", fields)
@classmethod
def from_raw_data(cls, raw_data):
"""Create a new model using raw API response."""
def process_raw_data(cls, raw_data):
"""Process the received data in order to be understood by the model."""
content = {}
properties = raw_data.pop("properties", {})
for field_name, field in cls._meta.fields.items():
@ -253,6 +284,12 @@ class Model(object):
if properties:
LOG.debug("Unrecognized properties: %r", properties)
return content
@classmethod
def from_raw_data(cls, raw_data):
"""Create a new model using raw API response."""
content = cls.process_raw_data(raw_data)
return cls(**content)
@property
@ -270,9 +307,10 @@ class Model(object):
def update(self, fields=None):
"""Update the value of one or more fields."""
for field_name, field in self._meta.fields.items():
if field_name in fields:
self._changes[field.key] = fields[field_name]
if fields and isinstance(fields, dict):
for field_name, field in self._meta.fields.items():
if field_name in fields:
self._changes[field.key] = fields[field_name]
self._data.update(self._changes)
def commit(self, wait=False, timeout=None):
@ -281,18 +319,17 @@ class Model(object):
self._data.update(self._changes)
self._changes.clear()
def dump(self, include_read_only=True):
def dump(self, include_read_only=True, include_static=False):
"""Create a dictionary with the content of the current model."""
content = {}
for field in self._meta.fields.values():
if field.is_read_only and not include_read_only:
continue
value = self._data.get(field.key)
if isinstance(value, Model):
# The raw content of the model is required
value = value.dump()
if field.is_static and not include_static:
continue
value = self._unpack(self._data.get(field.key))
if not field.is_required and value is None:
# The value of this field is not relevant
continue

View File

@ -0,0 +1,165 @@
# Copyright 2017 Cloudbase Solutions Srl
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# pylint: disable=protected-access
import unittest
try:
import unittest.mock as mock
except ImportError:
import mock
from hnv_client import client
from hnv_client.common import constant
from hnv_client.common import exception
from hnv_client import config as hnv_config
CONFIG = hnv_config.CONFIG
class TestBaseHNVModel(unittest.TestCase):
def setUp(self):
client._BaseHNVModel._endpoint = "{parent_id}/{resource_id}"
@mock.patch("hnv_client.client._BaseHNVModel.from_raw_data")
@mock.patch("hnv_client.client._BaseHNVModel._get_client")
def test_get(self, mock_get_client, mock_from_raw_data):
mock_from_raw_data.return_value = mock.sentinel.resource
http_client = mock_get_client.return_value = mock.Mock()
get_resource = http_client.get_resource = mock.Mock()
resource = client._BaseHNVModel.get(resource_id="hnv-client-test")
get_resource.assert_called_once_with("/hnv-client-test")
self.assertIs(resource, mock.sentinel.resource)
@mock.patch("hnv_client.client._BaseHNVModel.from_raw_data")
@mock.patch("hnv_client.client._BaseHNVModel._get_client")
def test_get_all(self, mock_get_client, mock_from_raw_data):
mock_from_raw_data.side_effect = range(10)
http_client = mock_get_client.return_value = mock.Mock()
get_resource = http_client.get_resource = mock.Mock()
get_resource.return_value = {"value": range(10)}
resources = client._BaseHNVModel.get()
get_resource.assert_called_once_with("/")
self.assertEqual(resources, range(10))
@mock.patch("time.sleep")
@mock.patch("hnv_client.client._BaseHNVModel._get_client")
def _test_remove(self, mock_get_client, mock_sleep,
loop_count, timeout):
http_client = mock_get_client.return_value = mock.Mock()
remove_resource = http_client.remove_resource = mock.Mock()
get_resource = http_client.get_resource = mock.Mock()
side_effect = [None for _ in range(loop_count)]
side_effect.append(exception.NotFound if not timeout else None)
get_resource.side_effect = side_effect
request_timeout = CONFIG.HNV.retry_interval * loop_count
request_wait = True if loop_count > 0 else False
if timeout:
self.assertRaises(exception.TimeOut, client._BaseHNVModel.remove,
"hnv-client-test", wait=request_wait,
timeout=request_timeout)
else:
client._BaseHNVModel.remove("hnv-client-test",
wait=request_wait,
timeout=request_timeout)
remove_resource.assert_called_once_with("/hnv-client-test")
def test_remove(self):
self._test_remove(loop_count=0, timeout=False)
def test_remove_with_wait(self):
self._test_remove(loop_count=3, timeout=False)
def test_remove_timeout(self):
self._test_remove(loop_count=1, timeout=True)
@staticmethod
def _get_provisioning(provisioning_state):
return {"properties": {"provisioningState": provisioning_state}}
@mock.patch("time.sleep")
@mock.patch("hnv_client.client._BaseHNVModel.process_raw_data")
@mock.patch("hnv_client.client._BaseHNVModel.dump")
@mock.patch("hnv_client.client._BaseHNVModel._get_client")
def _test_commit(self, mock_get_client, mock_dump, mock_process,
mock_sleep,
loop_count, timeout, failed, invalid_response):
http_client = mock_get_client.return_value = mock.Mock()
update_resource = http_client.update_resource = mock.Mock()
mock_dump.return_value = mock.sentinel.request_body
mock_process.return_value = {}
get_resource = http_client.get_resource = mock.Mock()
side_effect = [self._get_provisioning(constant.UPDATING)
for _ in range(loop_count)]
if timeout:
side_effect.append(self._get_provisioning(constant.UPDATING))
elif failed:
side_effect.append(self._get_provisioning(constant.FAILED))
elif invalid_response:
side_effect.append(self._get_provisioning(None))
else:
side_effect.append(self._get_provisioning(constant.SUCCEEDED))
get_resource.side_effect = side_effect
request_timeout = CONFIG.HNV.retry_interval * loop_count
request_wait = True if loop_count > 0 else False
model = client._BaseHNVModel(resource_id="hnv-client",
parent_id="test")
if invalid_response or failed:
self.assertRaises(exception.ServiceException, model.commit,
wait=request_wait, timeout=request_timeout)
elif timeout:
self.assertRaises(exception.TimeOut, model.commit,
wait=request_wait, timeout=request_timeout)
else:
model.commit(wait=request_wait, timeout=request_timeout)
mock_dump.assert_called_once_with(include_read_only=False)
update_resource.assert_called_once_with(
"test/hnv-client", data=mock.sentinel.request_body)
if request_wait:
self.assertEqual(get_resource.call_count, loop_count + 1)
def test_commit(self):
self._test_commit(loop_count=0, timeout=False,
failed=False, invalid_response=False)
def test_commit_with_wait(self):
self._test_commit(loop_count=3, timeout=False,
failed=False, invalid_response=False)
def test_commit_timeout(self):
self._test_commit(loop_count=1, timeout=True,
failed=False, invalid_response=False)
def test_commit_failed(self):
self._test_commit(loop_count=1, timeout=False,
failed=True, invalid_response=False)
def test_commit_invalid_response(self):
self._test_commit(loop_count=1, timeout=False,
failed=False, invalid_response=True)