diff --git a/openstack/connection.py b/openstack/connection.py index 3f6515480..5ee73fd55 100644 --- a/openstack/connection.py +++ b/openstack/connection.py @@ -65,6 +65,7 @@ import os_client_config from openstack import profile as _profile from openstack import proxy +from openstack import proxy2 from openstack import session as _session from openstack import utils @@ -226,7 +227,8 @@ class Connection(object): try: __import__(module) proxy_class = getattr(sys.modules[module], "Proxy") - if not issubclass(proxy_class, proxy.BaseProxy): + if not (issubclass(proxy_class, proxy.BaseProxy) or + issubclass(proxy_class, proxy2.BaseProxy)): raise TypeError("%s.Proxy must inherit from BaseProxy" % proxy_class.__module__) setattr(self, attr_name, proxy_class(self.session)) diff --git a/openstack/proxy2.py b/openstack/proxy2.py new file mode 100644 index 000000000..47464a336 --- /dev/null +++ b/openstack/proxy2.py @@ -0,0 +1,308 @@ +# 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. + +from openstack import exceptions +from openstack import resource2 + + +# The _check_resource decorator is used on BaseProxy methods to ensure that +# the `actual` argument is in fact the type of the `expected` argument. +# It does so under two cases: +# 1. When strict=False, if and only if `actual` is a Resource instance, +# it is checked to see that it's an instance of the `expected` class. +# This allows `actual` to be other types, such as strings, when it makes +# sense to accept a raw id value. +# 2. When strict=True, `actual` must be an instance of the `expected` class. +def _check_resource(strict=False): + def wrap(method): + def check(self, expected, actual=None, *args, **kwargs): + if (strict and actual is not None and not + isinstance(actual, resource2.Resource)): + raise ValueError("A %s must be passed" % expected.__name__) + elif (isinstance(actual, resource2.Resource) and not + isinstance(actual, expected)): + raise ValueError("Expected %s but received %s" % ( + expected.__name__, actual.__class__.__name__)) + + return method(self, expected, actual, *args, **kwargs) + return check + return wrap + + +class BaseProxy(object): + + def __init__(self, session): + self.session = session + + def _get_resource(self, resource_type, value, **attrs): + """Get a resource object to work on + + :param resource_type: The type of resource to operate on. This should + be a subclass of + :class:`~openstack.resource2.Resource` with a + ``from_id`` method. + :param value: The ID of a resource or an object of ``resource_type`` + class if using an existing instance, or None to create a + new instance. + :param path_args: A dict containing arguments for forming the request + URL, if needed. + """ + if value is None: + # Create a bare resource + res = resource_type.new(**attrs) + elif not isinstance(value, resource_type): + # Create from an ID + res = resource_type.new(id=value, **attrs) + else: + # An existing resource instance + res = value + res._update(**attrs) + + return res + + def _get_uri_attribute(self, child, parent, name): + """Get a value to be associated with a URI attribute + + `child` will not be None here as it's a required argument + on the proxy method. `parent` is allowed to be None if `child` + is an actual resource, but when an ID is given for the child + one must also be provided for the parent. An example of this + is that a parent is a Server and a child is a ServerInterface. + """ + if parent is None: + value = getattr(child, name) + else: + value = resource2.Resource._get_id(parent) + return value + + def _find(self, resource_type, name_or_id, ignore_missing=True, + **attrs): + """Find a resource + + :param name_or_id: The name or ID of a resource to find. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the resource does not exist. + When set to ``True``, None will be returned when + attempting to find a nonexistent resource2. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.find` + method, such as query parameters. + + :returns: An instance of ``resource_type`` or None + """ + return resource_type.find(self.session, name_or_id, + ignore_missing=ignore_missing, + **attrs) + + @_check_resource(strict=False) + def _delete(self, resource_type, value, ignore_missing=True, **attrs): + """Delete a resource + + :param resource_type: The type of resource to delete. This should + be a :class:`~openstack.resource2.Resource` + subclass with a ``from_id`` method. + :param value: The value to delete. Can be either the ID of a + resource or a :class:`~openstack.resource2.Resource` + subclass. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the resource does not exist. + When set to ``True``, no exception will be set when + attempting to delete a nonexistent resource2. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.delete` + method, such as the ID of a parent resource. + + :returns: The result of the ``delete`` + :raises: ``ValueError`` if ``value`` is a + :class:`~openstack.resource2.Resource` that doesn't match + the ``resource_type``. + :class:`~openstack.exceptions.ResourceNotFound` when + ignore_missing if ``False`` and a nonexistent resource + is attempted to be deleted. + + """ + res = self._get_resource(resource_type, value, **attrs) + + try: + rv = res.delete(self.session) + except exceptions.NotFoundException as e: + if ignore_missing: + return None + else: + # Reraise with a more specific type and message + raise exceptions.ResourceNotFound( + message="No %s found for %s" % + (resource_type.__name__, value), + details=e.details, response=e.response, + request_id=e.request_id, url=e.url, method=e.method, + http_status=e.http_status, cause=e.cause) + + return rv + + @_check_resource(strict=False) + def _update(self, resource_type, value, **attrs): + """Update a resource + + :param resource_type: The type of resource to update. + :type resource_type: :class:`~openstack.resource2.Resource` + :param value: The resource to update. This must either be a + :class:`~openstack.resource2.Resource` or an id + that corresponds to a resource2. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.update` + method to be updated. These should correspond + to either :class:`~openstack.resource2.Body` + or :class:`~openstack.resource2.Header` + values on this resource. + + :returns: The result of the ``update`` + :rtype: :class:`~openstack.resource2.Resource` + """ + res = self._get_resource(resource_type, value, **attrs) + return res.update(self.session) + + def _create(self, resource_type, **attrs): + """Create a resource from attributes + + :param resource_type: The type of resource to create. + :type resource_type: :class:`~openstack.resource2.Resource` + :param path_args: A dict containing arguments for forming the request + URL, if needed. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.create` + method to be created. These should correspond + to either :class:`~openstack.resource2.Body` + or :class:`~openstack.resource2.Header` + values on this resource. + + :returns: The result of the ``create`` + :rtype: :class:`~openstack.resource2.Resource` + """ + res = resource_type.new(**attrs) + return res.create(self.session) + + @_check_resource(strict=False) + def _get(self, resource_type, value=None, **attrs): + """Get a resource + + :param resource_type: The type of resource to get. + :type resource_type: :class:`~openstack.resource2.Resource` + :param value: The value to get. Can be either the ID of a + resource or a :class:`~openstack.resource2.Resource` + subclass. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.get` + method. These should correspond + to either :class:`~openstack.resource2.Body` + or :class:`~openstack.resource2.Header` + values on this resource. + + :returns: The result of the ``get`` + :rtype: :class:`~openstack.resource2.Resource` + """ + res = self._get_resource(resource_type, value, **attrs) + + try: + return res.get(self.session) + except exceptions.NotFoundException as e: + raise exceptions.ResourceNotFound( + message="No %s found for %s" % + (resource_type.__name__, value), + details=e.details, response=e.response, + request_id=e.request_id, url=e.url, method=e.method, + http_status=e.http_status, cause=e.cause) + + def _list(self, resource_type, value=None, paginated=False, **attrs): + """List a resource + + :param resource_type: The type of resource to delete. This should + be a :class:`~openstack.resource2.Resource` + subclass with a ``from_id`` method. + :param value: The resource to list. It can be the ID of a resource, or + a :class:`~openstack.resource2.Resource` object. When set + to None, a new bare resource is created. + :param bool paginated: When set to ``False``, expect all of the data + to be returned in one response. When set to + ``True``, the resource supports data being + returned across multiple pages. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.list` method. These should + correspond to either :class:`~openstack.resource2.URI` values + or appear in :data:`~openstack.resource2.Resource._query_mapping`. + + :returns: A generator of Resource objects. + :raises: ``ValueError`` if ``value`` is a + :class:`~openstack.resource2.Resource` that doesn't match + the ``resource_type``. + """ + res = self._get_resource(resource_type, value, **attrs) + return res.list(self.session, paginated=paginated, **attrs) + + def _head(self, resource_type, value=None, **attrs): + """Retrieve a resource's header + + :param resource_type: The type of resource to retrieve. + :type resource_type: :class:`~openstack.resource2.Resource` + :param value: The value of a specific resource to retreive headers + for. Can be either the ID of a resource, + a :class:`~openstack.resource2.Resource` subclass, + or ``None``. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.head` method. + These should correspond to + :class:`~openstack.resource2.URI` values. + + :returns: The result of the ``head`` call + :rtype: :class:`~openstack.resource2.Resource` + """ + res = self._get_resource(resource_type, value, **attrs) + return res.head(self.session) + + def wait_for_status(self, value, status, failures=[], interval=2, + wait=120): + """Wait for a resource to be in a particular status. + + :param value: The resource to wait on to reach the status. The + resource must have a status attribute. + :type value: :class:`~openstack.resource2.Resource` + :param status: Desired status of the resource2. + :param list failures: Statuses that would indicate the transition + failed such as 'ERROR'. + :param interval: Number of seconds to wait between checks. + :param wait: Maximum number of seconds to wait for the change. + + :return: Method returns resource on success. + :raises: :class:`~openstack.exceptions.ResourceTimeout` transition + to status failed to occur in wait seconds. + :raises: :class:`~openstack.exceptions.ResourceFailure` resource + transitioned to one of the failure states. + :raises: :class:`~AttributeError` if the resource does not have a + status attribute + """ + return resource2.wait_for_status(self.session, value, status, + failures, interval, wait) + + def wait_for_delete(self, value, interval=2, wait=120): + """Wait for the resource to be deleted. + + :param value: The resource to wait on to be deleted. + :type value: :class:`~openstack.resource2.Resource` + :param interval: Number of seconds to wait between checks. + :param wait: Maximum number of seconds to wait for the delete. + + :return: Method returns resource on success. + :raises: :class:`~openstack.exceptions.ResourceTimeout` transition + to delete failed to occur in wait seconds. + """ + return resource2.wait_for_delete(self.session, value, interval, wait) diff --git a/openstack/tests/unit/test_proxy2.py b/openstack/tests/unit/test_proxy2.py new file mode 100644 index 000000000..45d2725f3 --- /dev/null +++ b/openstack/tests/unit/test_proxy2.py @@ -0,0 +1,418 @@ +# 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. + +import mock +import testtools + +from openstack import exceptions +from openstack import proxy2 +from openstack import resource2 + + +class DeleteableResource(resource2.Resource): + allow_delete = True + + +class UpdateableResource(resource2.Resource): + allow_update = True + + +class CreateableResource(resource2.Resource): + allow_create = True + + +class RetrieveableResource(resource2.Resource): + allow_retrieve = True + + +class ListableResource(resource2.Resource): + allow_list = True + + +class HeadableResource(resource2.Resource): + allow_head = True + + +class TestProxyPrivate(testtools.TestCase): + + def setUp(self): + super(TestProxyPrivate, self).setUp() + + def method(self, expected_type, value): + return value + + self.sot = mock.Mock() + self.sot.method = method + + self.fake_proxy = proxy2.BaseProxy("session") + + def _test_correct(self, value): + decorated = proxy2._check_resource(strict=False)(self.sot.method) + rv = decorated(self.sot, resource2.Resource, value) + + self.assertEqual(value, rv) + + def test__check_resource_correct_resource(self): + res = resource2.Resource() + self._test_correct(res) + + def test__check_resource_notstrict_id(self): + self._test_correct("abc123-id") + + def test__check_resource_strict_id(self): + decorated = proxy2._check_resource(strict=True)(self.sot.method) + self.assertRaisesRegexp(ValueError, "A Resource must be passed", + decorated, self.sot, resource2.Resource, + "this-is-not-a-resource") + + def test__check_resource_incorrect_resource(self): + class OneType(resource2.Resource): + pass + + class AnotherType(resource2.Resource): + pass + + value = AnotherType() + decorated = proxy2._check_resource(strict=False)(self.sot.method) + self.assertRaisesRegexp(ValueError, + "Expected OneType but received AnotherType", + decorated, self.sot, OneType, value) + + def test__get_uri_attribute_no_parent(self): + class Child(resource2.Resource): + something = resource2.Body("something") + + attr = "something" + value = "nothing" + child = Child(something=value) + + result = self.fake_proxy._get_uri_attribute(child, None, attr) + + self.assertEqual(value, result) + + def test__get_uri_attribute_with_parent(self): + class Parent(resource2.Resource): + pass + + value = "nothing" + parent = Parent(id=value) + + result = self.fake_proxy._get_uri_attribute("child", parent, "attr") + + self.assertEqual(value, result) + + def test__get_resource_new(self): + value = "hello" + fake_type = mock.Mock(spec=resource2.Resource) + fake_type.new = mock.Mock(return_value=value) + attrs = {"first": "Brian", "last": "Curtin"} + + result = self.fake_proxy._get_resource(fake_type, None, **attrs) + + fake_type.new.assert_called_with(**attrs) + self.assertEqual(value, result) + + def test__get_resource_from_id(self): + id = "eye dee" + value = "hello" + attrs = {"first": "Brian", "last": "Curtin"} + + # The isinstance check needs to take a type, not an instance, + # so the mock.assert_called_with method isn't helpful here since + # we can't pass in a mocked object. This class is a crude version + # of that same behavior to let us check that `new` gets + # called with the expected arguments. + + class Fake(object): + call = {} + + @classmethod + def new(cls, **kwargs): + cls.call = kwargs + return value + + result = self.fake_proxy._get_resource(Fake, id, **attrs) + + self.assertDictEqual(dict(id=id, **attrs), Fake.call) + self.assertEqual(value, result) + + def test__get_resource_from_resource(self): + res = mock.Mock(spec=resource2.Resource) + res._update = mock.Mock() + + attrs = {"first": "Brian", "last": "Curtin"} + + result = self.fake_proxy._get_resource(resource2.Resource, + res, **attrs) + + res._update.assert_called_once_with(**attrs) + self.assertEqual(result, res) + + +class TestProxyDelete(testtools.TestCase): + + def setUp(self): + super(TestProxyDelete, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.res = mock.Mock(spec=DeleteableResource) + self.res.id = self.fake_id + self.res.delete = mock.Mock() + + self.sot = proxy2.BaseProxy(self.session) + DeleteableResource.new = mock.Mock(return_value=self.res) + + def test_delete(self): + self.sot._delete(DeleteableResource, self.res) + self.res.delete.assert_called_with(self.session) + + self.sot._delete(DeleteableResource, self.fake_id) + DeleteableResource.new.assert_called_with(id=self.fake_id) + self.res.delete.assert_called_with(self.session) + + # Delete generally doesn't return anything, so we will normally + # swallow any return from within a service's proxy, but make sure + # we can still return for any cases where values are returned. + self.res.delete.return_value = self.fake_id + rv = self.sot._delete(DeleteableResource, self.fake_id) + self.assertEqual(rv, self.fake_id) + + def test_delete_ignore_missing(self): + self.res.delete.side_effect = exceptions.NotFoundException( + message="test", http_status=404) + + rv = self.sot._delete(DeleteableResource, self.fake_id) + self.assertIsNone(rv) + + def test_delete_ResourceNotFound(self): + self.res.delete.side_effect = exceptions.NotFoundException( + message="test", http_status=404) + + self.assertRaisesRegexp( + exceptions.ResourceNotFound, + "No %s found for %s" % (DeleteableResource.__name__, self.res), + self.sot._delete, DeleteableResource, self.res, + ignore_missing=False) + + def test_delete_HttpException(self): + self.res.delete.side_effect = exceptions.HttpException( + message="test", http_status=500) + + self.assertRaises(exceptions.HttpException, self.sot._delete, + DeleteableResource, self.res, ignore_missing=False) + + +class TestProxyUpdate(testtools.TestCase): + + def setUp(self): + super(TestProxyUpdate, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.fake_result = "fake_result" + + self.res = mock.Mock(spec=UpdateableResource) + self.res.update = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + + self.attrs = {"x": 1, "y": 2, "z": 3} + + UpdateableResource.new = mock.Mock(return_value=self.res) + + def test_update_resource(self): + rv = self.sot._update(UpdateableResource, self.res, **self.attrs) + + self.assertEqual(rv, self.fake_result) + self.res._update.assert_called_once_with(**self.attrs) + self.res.update.assert_called_once_with(self.session) + + def test_update_id(self): + rv = self.sot._update(UpdateableResource, self.fake_id, **self.attrs) + + self.assertEqual(rv, self.fake_result) + self.res.update.assert_called_once_with(self.session) + + +class TestProxyCreate(testtools.TestCase): + + def setUp(self): + super(TestProxyCreate, self).setUp() + + self.session = mock.Mock() + + self.fake_result = "fake_result" + self.res = mock.Mock(spec=CreateableResource) + self.res.create = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + + def test_create_attributes(self): + CreateableResource.new = mock.Mock(return_value=self.res) + + attrs = {"x": 1, "y": 2, "z": 3} + rv = self.sot._create(CreateableResource, **attrs) + + self.assertEqual(rv, self.fake_result) + CreateableResource.new.assert_called_once_with(**attrs) + self.res.create.assert_called_once_with(self.session) + + +class TestProxyGet(testtools.TestCase): + + def setUp(self): + super(TestProxyGet, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.fake_name = "fake_name" + self.fake_result = "fake_result" + self.res = mock.Mock(spec=RetrieveableResource) + self.res.id = self.fake_id + self.res.get = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + RetrieveableResource.new = mock.Mock(return_value=self.res) + + def test_get_resource(self): + rv = self.sot._get(RetrieveableResource, self.res) + + self.res.get.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_get_resource_with_args(self): + args = {"key": "value"} + rv = self.sot._get(RetrieveableResource, self.res, **args) + + self.res._update.assert_called_once_with(**args) + self.res.get.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_get_id(self): + rv = self.sot._get(RetrieveableResource, self.fake_id) + + RetrieveableResource.new.assert_called_with(id=self.fake_id) + self.res.get.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_get_not_found(self): + self.res.get.side_effect = exceptions.NotFoundException( + message="test", http_status=404) + + self.assertRaisesRegexp( + exceptions.ResourceNotFound, + "No %s found for %s" % (RetrieveableResource.__name__, self.res), + self.sot._get, RetrieveableResource, self.res) + + +class TestProxyList(testtools.TestCase): + + def setUp(self): + super(TestProxyList, self).setUp() + + self.session = mock.Mock() + + self.args = {"a": "A", "b": "B", "c": "C"} + self.fake_response = [resource2.Resource()] + + self.sot = proxy2.BaseProxy(self.session) + ListableResource.list = mock.Mock() + ListableResource.list.return_value = self.fake_response + + def _test_list(self, paginated): + rv = self.sot._list(ListableResource, paginated=paginated, **self.args) + + self.assertEqual(self.fake_response, rv) + ListableResource.list.assert_called_once_with( + self.session, paginated=paginated, **self.args) + + def test_list_paginated(self): + self._test_list(True) + + def test_list_non_paginated(self): + self._test_list(False) + + +class TestProxyHead(testtools.TestCase): + + def setUp(self): + super(TestProxyHead, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.fake_name = "fake_name" + self.fake_result = "fake_result" + self.res = mock.Mock(spec=HeadableResource) + self.res.id = self.fake_id + self.res.head = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + HeadableResource.new = mock.Mock(return_value=self.res) + + def test_head_resource(self): + rv = self.sot._head(HeadableResource, self.res) + + self.res.head.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_head_id(self): + rv = self.sot._head(HeadableResource, self.fake_id) + + HeadableResource.new.assert_called_with(id=self.fake_id) + self.res.head.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + +class TestProxyWaits(testtools.TestCase): + + def setUp(self): + super(TestProxyWaits, self).setUp() + + self.session = mock.Mock() + self.sot = proxy2.BaseProxy(self.session) + + @mock.patch("openstack.resource2.wait_for_status") + def test_wait_for(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_status(mock_resource, 'ACTIVE') + mock_wait.assert_called_once_with( + self.session, mock_resource, 'ACTIVE', [], 2, 120) + + @mock.patch("openstack.resource2.wait_for_status") + def test_wait_for_params(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_status(mock_resource, 'ACTIVE', ['ERROR'], 1, 2) + mock_wait.assert_called_once_with( + self.session, mock_resource, 'ACTIVE', ['ERROR'], 1, 2) + + @mock.patch("openstack.resource2.wait_for_delete") + def test_wait_for_delete(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_delete(mock_resource) + mock_wait.assert_called_once_with( + self.session, mock_resource, 2, 120) + + @mock.patch("openstack.resource2.wait_for_delete") + def test_wait_for_delete_params(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_delete(mock_resource, 1, 2) + mock_wait.assert_called_once_with( + self.session, mock_resource, 1, 2)