placement: Add support for traits

Another weird one. There are two oddities here. Firstly, creation of a
trait is a PUT request and doesn't return a body, only a 'Location'. It
also doesn't error out if the trait already exists but rather returns a
HTTP 204. This is mostly handled by setting the 'create_method'
attribute. Secondly, the list response returns a list of strings rather
than a list of objects. This is less easily worked around and once again
requires a custom implementation of the 'list' class method.

We extend the 'QueryParameter' class to accept a new kwarg argument,
'include_pagination_defaults', which allows us to disable adding the
default 'limit' and 'marker' query string parameters: Placement doesn't
use these.

Change-Id: Idafa6c5c356d215224711b73c56a87ed7a690b94
Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
This commit is contained in:
Stephen Finucane 2023-07-12 12:26:54 +01:00
parent ebd4d75418
commit d54f4e30af
9 changed files with 340 additions and 13 deletions
doc/source/user
proxies
resources/placement
openstack
placement/v1
resource.py
tests
functional/placement/v1
unit/placement/v1
releasenotes/notes

@ -40,3 +40,10 @@ Resource Provider Inventories
delete_resource_provider_inventory,
get_resource_provider_inventory,
resource_provider_inventories
Traits
^^^^^^
.. autoclass:: openstack.placement.v1._proxy.Proxy
:noindex:
:members: create_trait, delete_trait, get_trait, traits

@ -7,3 +7,4 @@ Placement v1 Resources
v1/resource_class
v1/resource_provider
v1/resource_provider_inventory
v1/trait

@ -0,0 +1,12 @@
openstack.placement.v1.trait
============================
.. automodule:: openstack.placement.v1.trait
The Trait Class
---------------
The ``Trait`` class inherits from :class:`~openstack.resource.Resource`.
.. autoclass:: openstack.placement.v1.trait.Trait
:members:

@ -15,6 +15,7 @@ from openstack.placement.v1 import resource_provider as _resource_provider
from openstack.placement.v1 import (
resource_provider_inventory as _resource_provider_inventory,
)
from openstack.placement.v1 import trait as _trait
from openstack import proxy
from openstack import resource
@ -409,3 +410,53 @@ class Proxy(proxy.Proxy):
resource_provider_id=resource_provider_id,
**query,
)
# ========== Traits ==========
def create_trait(self, name):
"""Create a new trait
:param name: The name of the new trait
:returns: The results of trait creation
:rtype: :class:`~openstack.placement.v1.trait.Trait`
"""
return self._create(_trait.Trait, name=name)
def delete_trait(self, trait, ignore_missing=True):
"""Delete a trait
:param trait: The value can be either the ID of a trait or an
:class:`~openstack.placement.v1.trait.Trait`, instance.
:param bool ignore_missing: When set to ``False``
:class:`~openstack.exceptions.ResourceNotFound` will be raised when
the resource provider inventory does not exist. When set to
``True``, no exception will be set when attempting to delete a
nonexistent resource provider inventory.
:returns: ``None``
"""
self._delete(_trait.Trait, trait, ignore_missing=ignore_missing)
def get_trait(self, trait):
"""Get a single trait
:param trait: The value can be either the ID of a trait or an
:class:`~openstack.placement.v1.trait.Trait`, instance.
:returns: An instance of
:class:`~openstack.placement.v1.resource_provider_inventory.ResourceProviderInventory`
:raises: :class:`~openstack.exceptions.ResourceNotFound` when no
trait matching the criteria could be found.
"""
return self._get(_trait.Trait, trait)
def traits(self, **query):
"""Retrieve a generator of traits
:param query: Optional query parameters to be sent to limit
the resources being returned.
:returns: A generator of trait objects
"""
return self._list(_trait.Trait, **query)

@ -0,0 +1,142 @@
# 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 Trait(resource.Resource):
resource_key = None
resources_key = None
base_path = '/traits'
# Capabilities
allow_create = True
allow_fetch = True
allow_delete = True
allow_list = True
create_method = 'PUT'
# Added in 1.6
_max_microversion = '1.6'
_query_mapping = resource.QueryParameters(
'name',
'associated',
include_pagination_defaults=False,
)
name = resource.Body('name', alternate_id=True)
@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 the list of strings (as opposed to a list of objects) that this
call returns.
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 trait_name in data['traits']:
trait = {
'name': trait_name,
**uri_params,
}
value = cls.existing(
microversion=microversion,
connection=session._get_connection(),
**trait,
)
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

@ -321,25 +321,32 @@ class _Request:
class QueryParameters:
def __init__(self, *names, **mappings):
def __init__(
self,
*names,
include_pagination_defaults=True,
**mappings,
):
"""Create a dict of accepted query parameters
:param names: List of strings containing client-side query parameter
names. Each name in the list maps directly to the name
expected by the server.
:param mappings: Key-value pairs where the key is the client-side
name we'll accept here and the value is the name
the server expects, e.g, changes_since=changes-since.
Additionally, a value can be a dict with optional keys
name - server-side name,
type - callable to convert from client to server
representation.
the server expects, e.g, ``changes_since=changes-since``.
Additionally, a value can be a dict with optional keys:
By default, both limit and marker are included in the initial mapping
as they're the most common query parameters used for listing resources.
- ``name`` - server-side name,
- ``type`` - callable to convert from client to server
representation
:param include_pagination_defaults: If true, include default pagination
parameters, ``limit`` and ``marker``. These are the most common
query parameters used for listing resources in OpenStack APIs.
"""
self._mapping = {"limit": "limit", "marker": "marker"}
self._mapping = {}
if include_pagination_defaults:
self._mapping.update({"limit": "limit", "marker": "marker"})
self._mapping.update({name: name for name in names})
self._mapping.update(mappings)

@ -0,0 +1,61 @@
# 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 uuid
from openstack.placement.v1 import trait as _trait
from openstack.tests.functional import base
class TestTrait(base.BaseFunctionalTest):
def setUp(self):
super().setUp()
if not self.operator_cloud.has_service('placement'):
self.skipTest('placement service not supported by cloud')
self.trait_name = f'CUSTOM_{uuid.uuid4().hex.upper()}'
trait = self.operator_cloud.placement.create_trait(
name=self.trait_name,
)
self.assertIsInstance(trait, _trait.Trait)
self.assertEqual(self.trait_name, trait.name)
self.trait = trait
def tearDown(self):
self.operator_cloud.placement.delete_trait(self.trait)
super().tearDown()
def test_resource_provider_inventory(self):
# list all traits
traits = list(self.operator_cloud.placement.traits())
self.assertIsInstance(traits[0], _trait.Trait)
self.assertIn(self.trait.name, {x.id for x in traits})
# (no update_trait method)
# retrieve details of the trait
trait = self.operator_cloud.placement.get_trait(self.trait)
self.assertIsInstance(trait, _trait.Trait)
self.assertEqual(self.trait_name, trait.id)
# retrieve details of the trait using IDs
trait = self.operator_cloud.placement.get_trait(self.trait_name)
self.assertIsInstance(trait, _trait.Trait)
self.assertEqual(self.trait_name, trait.id)
# (no find_trait method)

@ -0,0 +1,42 @@
# 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.placement.v1 import trait as _trait
from openstack.tests.unit import base
FAKE = {
'name': 'CUSTOM_FOO',
}
class TestResourceClass(base.TestCase):
def test_basic(self):
sot = _trait.Trait()
self.assertEqual(None, sot.resource_key)
self.assertEqual(None, sot.resources_key)
self.assertEqual('/traits', sot.base_path)
self.assertTrue(sot.allow_create)
self.assertTrue(sot.allow_fetch)
self.assertFalse(sot.allow_commit)
self.assertTrue(sot.allow_delete)
self.assertTrue(sot.allow_list)
self.assertFalse(sot.allow_patch)
self.assertDictEqual(
{'name': 'name', 'associated': 'associated'},
sot._query_mapping._mapping,
)
def test_make_it(self):
sot = _trait.Trait(**FAKE)
self.assertEqual(FAKE['name'], sot.id)
self.assertEqual(FAKE['name'], sot.name)

@ -0,0 +1,4 @@
---
features:
- |
Add support for the ``Trait`` Placement resource.