From acf173d3eab577922d8f688123327d2c4accba15 Mon Sep 17 00:00:00 2001 From: Alexandru Coman Date: Mon, 30 Jan 2017 22:03:38 +0200 Subject: [PATCH] Add base HNV model --- hnv_client/client.py | 189 ++++++++++++++++++++++++++++++++ hnv_client/common/model.py | 87 ++++++++++----- hnv_client/tests/test_client.py | 165 ++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 hnv_client/client.py create mode 100644 hnv_client/tests/test_client.py diff --git a/hnv_client/client.py b/hnv_client/client.py new file mode 100644 index 0000000..526e2b4 --- /dev/null +++ b/hnv_client/client.py @@ -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 diff --git a/hnv_client/common/model.py b/hnv_client/common/model.py index 02e0a6b..e225492 100644 --- a/hnv_client/common/model.py +++ b/hnv_client/common/model.py @@ -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 diff --git a/hnv_client/tests/test_client.py b/hnv_client/tests/test_client.py new file mode 100644 index 0000000..bae8aef --- /dev/null +++ b/hnv_client/tests/test_client.py @@ -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)