Merge pull request #6 from alexcoman/feature/base-hnv-model
Add base HNV model
This commit is contained in:
commit
52f8d3e54e
189
hnv_client/client.py
Normal file
189
hnv_client/client.py
Normal 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
|
@ -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
|
||||
|
165
hnv_client/tests/test_client.py
Normal file
165
hnv_client/tests/test_client.py
Normal 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)
|
Loading…
x
Reference in New Issue
Block a user