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

View File

@ -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

View File

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

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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.
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.
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:
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)

View File

@ -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)

View File

@ -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)

View File

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