diff --git a/doc/source/user/proxies/image_v2.rst b/doc/source/user/proxies/image_v2.rst index b280e8198..e0b5caeec 100644 --- a/doc/source/user/proxies/image_v2.rst +++ b/doc/source/user/proxies/image_v2.rst @@ -84,6 +84,16 @@ Metadef Resource Type Operations create_metadef_resource_type_association, delete_metadef_resource_type_association + +Metadef Property Operations +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: openstack.image.v2._proxy.Proxy + :noindex: + :members: create_metadef_property, update_metadef_property, + delete_metadef_property, get_metadef_property + + Helpers ^^^^^^^ diff --git a/doc/source/user/resources/image/index.rst b/doc/source/user/resources/image/index.rst index 6cfe1ae65..c6ef3d78f 100644 --- a/doc/source/user/resources/image/index.rst +++ b/doc/source/user/resources/image/index.rst @@ -20,6 +20,7 @@ Image v2 Resources v2/metadef_namespace v2/metadef_object v2/metadef_resource_type + v2/metadef_property v2/metadef_schema v2/task v2/service_info diff --git a/doc/source/user/resources/image/v2/metadef_property.rst b/doc/source/user/resources/image/v2/metadef_property.rst new file mode 100644 index 000000000..e70fff6eb --- /dev/null +++ b/doc/source/user/resources/image/v2/metadef_property.rst @@ -0,0 +1,13 @@ +openstack.image.v2.metadef_property +=================================== + +.. automodule:: openstack.image.v2.metadef_property + +The MetadefProperty Class +------------------------- + +The ``MetadefProperty`` class inherits from +:class:`~openstack.resource.Resource`. + +.. autoclass:: openstack.image.v2.metadef_property.MetadefProperty + :members: diff --git a/openstack/image/v2/_proxy.py b/openstack/image/v2/_proxy.py index fd2e6741f..5e1ccdbd3 100644 --- a/openstack/image/v2/_proxy.py +++ b/openstack/image/v2/_proxy.py @@ -20,6 +20,7 @@ from openstack.image.v2 import image as _image from openstack.image.v2 import member as _member from openstack.image.v2 import metadef_namespace as _metadef_namespace from openstack.image.v2 import metadef_object as _metadef_object +from openstack.image.v2 import metadef_property as _metadef_property from openstack.image.v2 import metadef_resource_type as _metadef_resource_type from openstack.image.v2 import metadef_schema as _metadef_schema from openstack.image.v2 import schema as _schema @@ -1397,6 +1398,132 @@ class Proxy(proxy.Proxy): **query, ) + # ====== METADEF PROPERTY ====== + def create_metadef_property(self, metadef_namespace, **attrs): + """Create a metadef property + + :param metadef_namespace: The value can be either the name of metadef + namespace or an + :class:`~openstack.image.v2.metadef_property.MetadefNamespace` + instance + :param attrs: The attributes to create on the metadef property + represented by ``metadef_property``. + + :returns: The created metadef property + :rtype: :class:`~openstack.image.v2.metadef_property.MetadefProperty` + """ + namespace_name = resource.Resource._get_id(metadef_namespace) + return self._create( + _metadef_property.MetadefProperty, + namespace_name=namespace_name, + **attrs, + ) + + def update_metadef_property( + self, metadef_property, metadef_namespace, **attrs + ): + """Update a metadef property + + :param metadef_property: The value can be either the name of metadef + property or an + :class:`~openstack.image.v2.metadef_property.MetadefProperty` + instance. + :param metadef_namespace: The value can be either the name of metadef + namespace or an + :class:`~openstack.image.v2.metadef_namespace.MetadefNamespace` + instance + :param attrs: The attributes to update on the metadef property + represented by ``metadef_property``. + + :returns: The updated metadef property + :rtype: :class:`~openstack.image.v2.metadef_property.MetadefProperty` + """ + namespace_name = resource.Resource._get_id(metadef_namespace) + metadef_property = resource.Resource._get_id(metadef_property) + return self._update( + _metadef_property.MetadefProperty, + metadef_property, + namespace_name=namespace_name, + **attrs, + ) + + def delete_metadef_property( + self, metadef_property, metadef_namespace, ignore_missing=True + ): + """Delete a metadef property + + :param metadef_property: The value can be either the name of metadef + property or an + :class:`~openstack.image.v2.metadef_property.MetadefProperty` + instance + :param metadef_namespace: The value can be either the name of metadef + namespace or an + :class:`~openstack.image.v2.metadef_namespace.MetadefNamespace` + instance + :param bool ignore_missing: When set to + ``False`` :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the instance does not exist. When set to ``True``, + no exception will be set when attempting to delete a nonexistent + instance. + + :returns: ``None`` + """ + namespace_name = resource.Resource._get_id(metadef_namespace) + metadef_property = resource.Resource._get_id(metadef_property) + return self._delete( + _metadef_property.MetadefProperty, + metadef_property, + namespace_name=namespace_name, + ignore_missing=ignore_missing, + ) + + def metadef_properties(self, metadef_namespace, **query): + """Return a generator of metadef properties + + :param metadef_namespace: The value can be either the name of metadef + namespace or an + :class:`~openstack.image.v2.metadef_namespace.MetadefNamespace` + instance + :param kwargs query: Optional query parameters to be sent to limit + the resources being returned. + + :returns: A generator of property objects + """ + namespace_name = resource.Resource._get_id(metadef_namespace) + return self._list( + _metadef_property.MetadefProperty, + requires_id=False, + namespace_name=namespace_name, + **query, + ) + + def get_metadef_property( + self, metadef_property, metadef_namespace, **query + ): + """Get a single metadef property + + :param metadef_property: The value can be either the name of metadef + property or an + :class:`~openstack.image.v2.metadef_property.MetadefProperty` + instance. + :param metadef_namespace: The value can be either the name of metadef + namespace or an + :class:`~openstack.image.v2.metadef_namespace.MetadefNamespace` + instance + + :returns: One + :class:`~~openstack.image.v2.metadef_property.MetadefProperty` + :raises: :class:`~openstack.exceptions.ResourceNotFound` when no + resource can be found. + """ + namespace_name = resource.Resource._get_id(metadef_namespace) + return self._get( + _metadef_property.MetadefProperty, + metadef_property, + namespace_name=namespace_name, + **query, + ) + # ====== SCHEMAS ====== def get_images_schema(self): """Get images schema diff --git a/openstack/image/v2/metadef_property.py b/openstack/image/v2/metadef_property.py new file mode 100644 index 000000000..ae40ebd4b --- /dev/null +++ b/openstack/image/v2/metadef_property.py @@ -0,0 +1,179 @@ +# 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 resource + + +class MetadefProperty(resource.Resource): + base_path = '/metadefs/namespaces/%(namespace_name)s/properties' + + # capabilities + allow_create = True + allow_fetch = True + allow_commit = True + allow_delete = True + allow_list = True + + #: An identifier (a name) for the namespace. + namespace_name = resource.URI('namespace_name') + #: The name of the property + name = resource.Body('name', alternate_id=True) + #: The property type. + type = resource.Body('type') + #: The title of the property. + title = resource.Body('title') + #: Detailed description of the property. + description = resource.Body('description') + #: A list of operator + operators = resource.Body('operators', type=list) + #: Default property description. + default = resource.Body('default') + #: Indicates whether this is a read-only property. + is_readonly = resource.Body('readonly', type=bool) + #: Minimum allowed numerical value. + minimum = resource.Body('minimum', type=int) + #: Maximum allowed numerical value. + maximum = resource.Body('maximum', type=int) + #: Enumerated list of property values. + enum = resource.Body('enum', type=list) + #: A regular expression + #: (`ECMA 262 `_) + #: that a string value must match. + pattern = resource.Body('pattern') + #: Minimum allowed string length. + min_length = resource.Body('minLength', type=int, minimum=0, default=0) + #: Maximum allowed string length. + max_length = resource.Body('maxLength', type=int, minimum=0) + #: Schema for the items in an array. + items = resource.Body('items', type=dict) + #: Indicates whether all values in the array must be distinct. + require_unique_items = resource.Body( + 'uniqueItems', type=bool, default=False + ) + #: Minimum length of an array. + min_items = resource.Body('minItems', type=int, minimum=0, default=0) + #: Maximum length of an array. + max_items = resource.Body('maxItems', type=int, minimum=0) + #: Describes extra items, if you use tuple typing. If the value of + #: ``items`` is an array (tuple typing) and the instance is longer than + #: the list of schemas in ``items``, the additional items are described by + #: the schema in this property. If this value is ``false``, the instance + #: cannot be longer than the list of schemas in ``items``. If this value + #: is ``true``, that is equivalent to the empty schema (anything goes). + allow_additional_items = resource.Body('additionalItems', type=bool) + + # TODO(stephenfin): It would be nicer if we could do this in Resource + # itself since the logic is also found elsewhere (e.g. + # openstack.identity.v2.extension.Extension) but that code is a bit of a + # rat's nest right now and needs a spring clean + @classmethod + def list( + cls, + session, + paginated=True, + base_path=None, + allow_unknown_params=False, + *, + microversion=None, + **params, + ): + """This method is a generator which yields resource objects. + + A re-implementation of :meth:`~openstack.resource.Resource.list` that + handles glance's single, unpaginated list implementation. + + Refer to :meth:`~openstack.resource.Resource.list` for full + documentation including parameter, exception and return type + documentation. + """ + session = cls._get_session(session) + + if microversion is None: + microversion = cls._get_microversion(session, action='list') + + if base_path is None: + base_path = cls.base_path + + # There is no server-side filtering, only client-side + client_filters = {} + # Gather query parameters which are not supported by the server + for k, v in params.items(): + if ( + # Known attr + hasattr(cls, k) + # Is real attr property + and isinstance(getattr(cls, k), resource.Body) + # not included in the query_params + and k not in cls._query_mapping._mapping.keys() + ): + client_filters[k] = v + + uri = base_path % params + uri_params = {} + + for k, v in params.items(): + # We need to gather URI parts to set them on the resource later + if hasattr(cls, k) and isinstance(getattr(cls, k), resource.URI): + uri_params[k] = v + + def _dict_filter(f, d): + """Dict param based filtering""" + if not d: + return False + for key in f.keys(): + if isinstance(f[key], dict): + if not _dict_filter(f[key], d.get(key, None)): + return False + elif d.get(key, None) != f[key]: + return False + return True + + response = session.get( + uri, + headers={"Accept": "application/json"}, + params={}, + microversion=microversion, + ) + exceptions.raise_from_response(response) + data = response.json() + + for name, property_data in data['properties'].items(): + property = { + 'name': name, + **property_data, + **uri_params, + } + value = cls.existing( + microversion=microversion, + connection=session._get_connection(), + **property, + ) + + filters_matched = True + # Iterate over client filters and return only if matching + for key in client_filters.keys(): + if isinstance(client_filters[key], dict): + if not _dict_filter( + client_filters[key], + value.get(key, None), + ): + filters_matched = False + break + elif value.get(key, None) != client_filters[key]: + filters_matched = False + break + + if filters_matched: + yield value + + return None diff --git a/openstack/tests/functional/image/v2/test_metadef_property.py b/openstack/tests/functional/image/v2/test_metadef_property.py new file mode 100644 index 000000000..c58edf7a4 --- /dev/null +++ b/openstack/tests/functional/image/v2/test_metadef_property.py @@ -0,0 +1,129 @@ +# 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 random +import string + +from openstack.image.v2 import metadef_namespace as _metadef_namespace +from openstack.image.v2 import metadef_property as _metadef_property +from openstack.tests.functional.image.v2 import base + + +class TestMetadefProperty(base.BaseImageTest): + def setUp(self): + super().setUp() + + # there's a limit on namespace length + namespace = 'test_' + ''.join( + random.choice(string.ascii_lowercase) for _ in range(75) + ) + self.metadef_namespace = self.conn.image.create_metadef_namespace( + namespace=namespace, + ) + self.assertIsInstance( + self.metadef_namespace, + _metadef_namespace.MetadefNamespace, + ) + self.assertEqual(namespace, self.metadef_namespace.namespace) + + # there's a limit on property length + property_name = 'test_' + ''.join( + random.choice(string.ascii_lowercase) for _ in range(75) + ) + self.attrs = { + 'name': property_name, + 'title': property_name, + 'type': 'string', + 'description': 'Web Server port', + 'enum': ["80", "443"], + } + self.metadef_property = self.conn.image.create_metadef_property( + self.metadef_namespace.namespace, **self.attrs + ) + self.assertIsInstance( + self.metadef_property, _metadef_property.MetadefProperty + ) + self.assertEqual(self.attrs['name'], self.metadef_property.name) + self.assertEqual(self.attrs['title'], self.metadef_property.title) + self.assertEqual(self.attrs['type'], self.metadef_property.type) + self.assertEqual( + self.attrs['description'], self.metadef_property.description + ) + self.assertEqual(self.attrs['enum'], self.metadef_property.enum) + + def tearDown(self): + # we do this in tearDown rather than via 'addCleanup' since we want to + # wait for the deletion of the resource to ensure it completes + self.conn.image.delete_metadef_property( + self.metadef_property, self.metadef_namespace + ) + self.conn.image.delete_metadef_namespace(self.metadef_namespace) + self.conn.image.wait_for_delete(self.metadef_namespace) + + super().tearDown() + + def test_metadef_property(self): + # get metadef property + metadef_property = self.conn.image.get_metadef_property( + self.metadef_property, self.metadef_namespace + ) + self.assertIsNotNone(metadef_property) + self.assertIsInstance( + metadef_property, _metadef_property.MetadefProperty + ) + self.assertEqual(self.attrs['name'], metadef_property.name) + self.assertEqual(self.attrs['title'], metadef_property.title) + self.assertEqual(self.attrs['type'], metadef_property.type) + self.assertEqual( + self.attrs['description'], metadef_property.description + ) + self.assertEqual(self.attrs['enum'], metadef_property.enum) + + # (no find_metadef_property method) + + # list + metadef_properties = list( + self.conn.image.metadef_properties(self.metadef_namespace) + ) + self.assertIsNotNone(metadef_properties) + self.assertIsInstance( + metadef_properties[0], _metadef_property.MetadefProperty + ) + + # update + self.attrs['title'] = ''.join( + random.choice(string.ascii_lowercase) for _ in range(10) + ) + self.attrs['description'] = ''.join( + random.choice(string.ascii_lowercase) for _ in range(10) + ) + metadef_property = self.conn.image.update_metadef_property( + self.metadef_property, + self.metadef_namespace.namespace, + **self.attrs + ) + self.assertIsNotNone(metadef_property) + self.assertIsInstance( + metadef_property, + _metadef_property.MetadefProperty, + ) + metadef_property = self.conn.image.get_metadef_property( + self.metadef_property.name, self.metadef_namespace + ) + self.assertEqual( + self.attrs['title'], + metadef_property.title, + ) + self.assertEqual( + self.attrs['description'], + metadef_property.description, + ) diff --git a/openstack/tests/unit/image/v2/test_metadef_property.py b/openstack/tests/unit/image/v2/test_metadef_property.py new file mode 100644 index 000000000..cfd7768a8 --- /dev/null +++ b/openstack/tests/unit/image/v2/test_metadef_property.py @@ -0,0 +1,82 @@ +# 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.image.v2 import metadef_property +from openstack.tests.unit import base + +EXAMPLE = { + 'namespace_name': 'CIM::StorageAllocationSettingData', + 'name': 'Access', + 'type': 'string', + 'title': 'Access', + 'description': ( + 'Access describes whether the allocated storage extent is ' + '1 (readable), 2 (writeable), or 3 (both).' + ), + 'operators': [''], + 'default': None, + 'readonly': None, + 'minimum': None, + 'maximum': None, + 'enum': [ + 'Unknown', + 'Readable', + 'Writeable', + 'Read/Write Supported', + 'DMTF Reserved', + ], + 'pattern': None, + 'min_length': 0, + 'max_length': None, + 'items': None, + 'unique_items': False, + 'min_items': 0, + 'max_items': None, + 'additional_items': None, +} + + +class TestMetadefProperty(base.TestCase): + def test_basic(self): + sot = metadef_property.MetadefProperty() + self.assertEqual( + '/metadefs/namespaces/%(namespace_name)s/properties', sot.base_path + ) + self.assertTrue(sot.allow_create) + self.assertTrue(sot.allow_fetch) + self.assertTrue(sot.allow_commit) + self.assertTrue(sot.allow_delete) + self.assertTrue(sot.allow_list) + + def test_make_it(self): + sot = metadef_property.MetadefProperty(**EXAMPLE) + self.assertEqual(EXAMPLE['namespace_name'], sot.namespace_name) + self.assertEqual(EXAMPLE['name'], sot.name) + self.assertEqual(EXAMPLE['type'], sot.type) + self.assertEqual(EXAMPLE['title'], sot.title) + self.assertEqual(EXAMPLE['description'], sot.description) + self.assertListEqual(EXAMPLE['operators'], sot.operators) + self.assertEqual(EXAMPLE['default'], sot.default) + self.assertEqual(EXAMPLE['readonly'], sot.is_readonly) + self.assertEqual(EXAMPLE['minimum'], sot.minimum) + self.assertEqual(EXAMPLE['maximum'], sot.maximum) + self.assertListEqual(EXAMPLE['enum'], sot.enum) + self.assertEqual(EXAMPLE['pattern'], sot.pattern) + self.assertEqual(EXAMPLE['min_length'], sot.min_length) + self.assertEqual(EXAMPLE['max_length'], sot.max_length) + self.assertEqual(EXAMPLE['items'], sot.items) + self.assertEqual(EXAMPLE['unique_items'], sot.require_unique_items) + self.assertEqual(EXAMPLE['min_items'], sot.min_items) + self.assertEqual(EXAMPLE['max_items'], sot.max_items) + self.assertEqual( + EXAMPLE['additional_items'], sot.allow_additional_items + ) diff --git a/releasenotes/notes/add-image-metadef-property-fb87e5a7090e73ac.yaml b/releasenotes/notes/add-image-metadef-property-fb87e5a7090e73ac.yaml new file mode 100644 index 000000000..042d4c71a --- /dev/null +++ b/releasenotes/notes/add-image-metadef-property-fb87e5a7090e73ac.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Added support for the ``MetadefProperty`` Image resource.