diff --git a/api-ref/source/v1/hosts.inc b/api-ref/source/v1/hosts.inc index a281c50a..c7692618 100644 --- a/api-ref/source/v1/hosts.inc +++ b/api-ref/source/v1/hosts.inc @@ -128,6 +128,7 @@ Request .. rest_parameters:: parameters.yaml - host_id: host_id_path + - private: property_private Response -------- @@ -328,3 +329,90 @@ Response .. literalinclude:: ../../../doc/api_samples/hosts/allocation-get-resp.json :language: javascript + +List Resource Properties +======================== + +.. rest_method:: GET v1/os-hosts/properties + +Get all resource properties from host + +**Response codes** + +Normal response code: 200 + +Error response codes: Bad Request(400), Unauthorized(401), Forbidden(403), +Internal Server Error(500) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - detail: resource_property_detail + - all: resource_property_all + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - resource_properties: resource_properties + - property: resource_properties_property + - private: resource_properties_private + - values: resource_properties_values + +**Example of List Resource Properties Response** + +.. literalinclude:: ../../../doc/api_samples/hosts/host-property-list.json + :language: javascript + +**Example of List Resource Properties With Detail Response** + +.. literalinclude:: ../../../doc/api_samples/hosts/host-property-list-detail.json + :language: javascript + +Update Resource Properties +========================== + +.. rest_method:: PATCH v1/os-hosts/properties/{property_name} + +Update a host resource properties + +**Response codes** + +Normal response code: 200 + +Error response codes: Bad Request(400), Unauthorized(401), Forbidden(403), +Internal Server Error(500) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - property_name: property_name + - private: property_private + +**Example of Update Resource Properties** + +.. literalinclude:: ../../../doc/api_samples/hosts/host-property-update.json + :language: javascript + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - created_at: created_at + - updated_at: updated_at + - id: resource_property_id + - resource_type: resource_property_resource_type + - property_name: resource_properties_property + - private: resource_property_private + +**Example of List Resource Properties Response** + +.. literalinclude:: ../../../doc/api_samples/hosts/host-property-update-res.json + :language: javascript + diff --git a/api-ref/source/v1/parameters.yaml b/api-ref/source/v1/parameters.yaml index 4e48ac81..9ab13390 100644 --- a/api-ref/source/v1/parameters.yaml +++ b/api-ref/source/v1/parameters.yaml @@ -42,6 +42,12 @@ lease_id_path: in: path required: true type: string +property_name: + description: | + The name of the property. + in: path + required: true + type: string # variables in query @@ -57,6 +63,19 @@ allocation_reservation_id_query: in: query required: false type: string +resource_property_all: + description: | + Whether to include all resource properties, public and private. + in: query + required: false + type: string +resource_property_detail: + description: | + Whether to include values along for each property and if the property + is private. + in: query + required: false + type: string # variables in body @@ -406,6 +425,13 @@ leases: in: body required: true type: array +property_private: + description: | + Whether the property is private. + + in: body + required: true + type: boolean reservation: description: | A ``reservation`` object. @@ -627,6 +653,69 @@ reservations_optional: in: body required: false type: array +resource_properties: + description: | + A list of ``resource_property`` objects. + + in: body + required: true + type: array +resource_properties_private: + description: | + Whether the property is private. + + in: body + required: false + type: boolean +resource_properties_property: + description: | + The name of the property. + + in: body + required: true + type: any +resource_properties_values: + description: | + A list of values for the property. + + in: body + required: false + type: array +resource_property: + description: | + The updated ``resource_property`` object. + + in: body + required: true + type: any +resource_property_id: + description: | + The updated ``resource_property`` UUID. + + in: body + required: true + type: string +resource_property_private: + description: | + Whether the updated ``resource_property`` is private. + + in: body + required: true + type: boolean +resource_property_property_name: + description: | + The updated ``resource_property`` property_name. + + in: body + required: true + type: string +resource_property_resource_type: + description: | + The updated ``resource_property`` resource type. + + in: body + required: true + type: string updated_at: description: | The date and time when the object was updated. diff --git a/blazar/api/v1/oshosts/service.py b/blazar/api/v1/oshosts/service.py index 565a5034..b42f26ea 100644 --- a/blazar/api/v1/oshosts/service.py +++ b/blazar/api/v1/oshosts/service.py @@ -86,3 +86,14 @@ class API(object): :type query: dict """ return self.manager_rpcapi.get_allocations(host_id, query) + + @policy.authorize('oshosts', 'get_resource_properties') + def list_resource_properties(self, query): + """List resource properties for hosts.""" + return self.manager_rpcapi.list_resource_properties(query) + + @policy.authorize('oshosts', 'update_resource_properties') + def update_resource_property(self, property_name, data): + """Update a host resource property.""" + return self.manager_rpcapi.update_resource_property( + property_name, data) diff --git a/blazar/api/v1/oshosts/v1_0.py b/blazar/api/v1/oshosts/v1_0.py index 1123c502..8db12680 100644 --- a/blazar/api/v1/oshosts/v1_0.py +++ b/blazar/api/v1/oshosts/v1_0.py @@ -79,3 +79,17 @@ def allocations_list(req, query): def allocations_get(req, host_id, query): """List all allocations on a specific host.""" return api_utils.render(allocation=_api.get_allocations(host_id, query)) + + +@rest.get('/properties', query=True) +def resource_properties_list(req, query=None): + """List computehost resource properties.""" + return api_utils.render( + resource_properties=_api.list_resource_properties(query)) + + +@rest.patch('/properties/') +def resource_property_update(req, property_name, data): + """Update a computehost resource property.""" + return api_utils.render( + resource_property=_api.update_resource_property(property_name, data)) diff --git a/blazar/api/v1/utils.py b/blazar/api/v1/utils.py index 1825ed41..a9533ec7 100644 --- a/blazar/api/v1/utils.py +++ b/blazar/api/v1/utils.py @@ -53,6 +53,9 @@ class Rest(flask.Blueprint): def put(self, rule, status_code=200): return self._mroute('PUT', rule, status_code) + def patch(self, rule, status_code=200): + return self._mroute('PATCH', rule, status_code) + def delete(self, rule, status_code=204): return self._mroute('DELETE', rule, status_code) @@ -79,7 +82,7 @@ class Rest(flask.Blueprint): if status: flask.request.status_code = status - if flask.request.method in ['POST', 'PUT']: + if flask.request.method in ['POST', 'PUT', 'PATCH']: kwargs['data'] = request_data() if flask.request.endpoint in self.routes_with_query_support: diff --git a/blazar/db/api.py b/blazar/db/api.py index 8deeb78b..2f9d3d05 100644 --- a/blazar/db/api.py +++ b/blazar/db/api.py @@ -385,13 +385,11 @@ def host_extra_capability_create(values): return IMPL.host_extra_capability_create(values) -@to_dict def host_extra_capability_get(host_extra_capability_id): """Return a specific Host Extracapability.""" return IMPL.host_extra_capability_get(host_extra_capability_id) -@to_dict def host_extra_capability_get_all_per_host(host_id): """Return all extra_capabilities belonging to a specific Compute host.""" return IMPL.host_extra_capability_get_all_per_host(host_id) @@ -410,7 +408,6 @@ def host_extra_capability_update(host_extra_capability_id, values): def host_extra_capability_get_all_per_name(host_id, extra_capability_name): return IMPL.host_extra_capability_get_all_per_name(host_id, - extra_capability_name) @@ -525,3 +522,17 @@ def reservable_fip_get_all_by_queries(queries): def floatingip_destroy(floatingip_id): """Delete specific floating ip.""" IMPL.floatingip_destroy(floatingip_id) + + +# Resource Properties + +def resource_properties_list(resource_type): + return IMPL.resource_properties_list(resource_type) + + +def resource_property_update(resource_type, property_name, values): + return IMPL.resource_property_update(resource_type, property_name, values) + + +def resource_property_create(values): + return IMPL.resource_property_create(values) diff --git a/blazar/db/exceptions.py b/blazar/db/exceptions.py index bef0e54d..7d010282 100644 --- a/blazar/db/exceptions.py +++ b/blazar/db/exceptions.py @@ -40,3 +40,12 @@ class BlazarDBInvalidFilter(BlazarDBException): class BlazarDBInvalidFilterOperator(BlazarDBException): msg_fmt = _('%(filter_operator)s is invalid') + + +class BlazarDBResourcePropertiesNotEnabled(BlazarDBException): + msq_fmt = _('%(resource_type)s does not have resource properties enabled.') + + +class BlazarDBInvalidResourceProperty(BlazarDBException): + msg_fmt = _('%(property_name)s does not exist for resource type ' + '%(resource_type)s.') diff --git a/blazar/db/migration/alembic_migrations/versions/02e2f2186d98_resource_property.py b/blazar/db/migration/alembic_migrations/versions/02e2f2186d98_resource_property.py new file mode 100644 index 00000000..89d30170 --- /dev/null +++ b/blazar/db/migration/alembic_migrations/versions/02e2f2186d98_resource_property.py @@ -0,0 +1,101 @@ +# Copyright 2022 OpenStack Foundation. +# +# 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. + +"""resource property + +Revision ID: 02e2f2186d98 +Revises: f4084140f608 +Create Date: 2020-04-17 15:51:40.542459 + +""" + +# revision identifiers, used by Alembic. +revision = '02e2f2186d98' +down_revision = 'f4084140f608' + +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + op.create_table('resource_properties', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('resource_type', sa.String(255), nullable=False), + sa.Column('property_name', sa.String(255), + nullable=False), + sa.Column('private', sa.Boolean, nullable=False, + server_default=sa.false()), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('resource_type', 'property_name')) + + if op.get_bind().engine.name != 'sqlite': + connection = op.get_bind() + + host_query = connection.execute(""" + SELECT DISTINCT "physical:host", capability_name + FROM computehost_extra_capabilities;""") + + capability_values = [ + (str(uuid.uuid4()), resource_type, capability_name) + for resource_type, capability_name + in host_query.fetchall()] + + if capability_values: + insert = """ + INSERT INTO resource_properties + (id, resource_type, property_name) + VALUES {};""" + connection.execute( + insert.format(', '.join(map(str, capability_values)))) + + op.add_column('computehost_extra_capabilities', + sa.Column('property_id', sa.String(length=255), + nullable=False)) + + connection.execute(""" + UPDATE computehost_extra_capabilities c + LEFT JOIN resource_properties e + ON e.property_name = c.capability_name + SET c.property_id = e.id;""") + + op.create_foreign_key('computehost_resource_property_id_fk', + 'computehost_extra_capabilities', + 'resource_properties', ['property_id'], ['id']) + op.drop_column('computehost_extra_capabilities', 'capability_name') + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('computehost_extra_capabilities', + sa.Column('capability_name', mysql.VARCHAR(length=64), + nullable=False)) + + if op.get_bind().engine.name != 'sqlite': + connection = op.get_bind() + connection.execute(""" + UPDATE computehost_extra_capabilities c + LEFT JOIN resource_properties e + ON e.id=c.property_id + SET c.capability_name = e.property_name;""") + op.drop_constraint('computehost_resource_property_id_fk', + 'computehost_extra_capabilities', + type_='foreignkey') + op.drop_column('computehost_extra_capabilities', 'property_id') + op.drop_table('resource_properties') diff --git a/blazar/db/sqlalchemy/api.py b/blazar/db/sqlalchemy/api.py index 97752d1d..e394179a 100644 --- a/blazar/db/sqlalchemy/api.py +++ b/blazar/db/sqlalchemy/api.py @@ -29,6 +29,9 @@ from blazar.db import exceptions as db_exc from blazar.db.sqlalchemy import facade_wrapper from blazar.db.sqlalchemy import models +RESOURCE_PROPERTY_MODELS = { + 'physical:host': models.ComputeHostExtraCapability, +} LOG = logging.getLogger(__name__) @@ -663,26 +666,28 @@ def host_get_all_by_queries(queries): hosts_query = hosts_query.filter(filt) else: - # looking for extra capabilities matches - extra_filter = model_query( - models.ComputeHostExtraCapability, get_session() - ).filter(models.ComputeHostExtraCapability.capability_name == key - ).all() + # looking for resource properties matches + extra_filter = ( + _host_resource_property_query(get_session()) + .filter(models.ResourceProperty.property_name == key) + ).all() + if not extra_filter: raise db_exc.BlazarDBNotFound( id=key, model='ComputeHostExtraCapability') - for host in extra_filter: + for host, property_name in extra_filter: + print(dir(host)) if op in oper and oper[op][1](host.capability_value, value): hosts.append(host.computehost_id) elif op not in oper: - msg = 'Operator %s for extra capabilities not implemented' + msg = 'Operator %s for resource properties not implemented' raise NotImplementedError(msg % op) # We must also avoid selecting any host which doesn't have the # extra capability present. all_hosts = [h.id for h in hosts_query.all()] - extra_filter_hosts = [h.computehost_id for h in extra_filter] + extra_filter_hosts = [h.computehost_id for h, _ in extra_filter] hosts += [h for h in all_hosts if h not in extra_filter_hosts] return hosts_query.filter(~models.ComputeHost.id.in_(hosts)).all() @@ -755,9 +760,19 @@ def host_destroy(host_id): # ComputeHostExtraCapability + +def _host_resource_property_query(session): + return ( + model_query(models.ComputeHostExtraCapability, session) + .join(models.ResourceProperty) + .add_column(models.ResourceProperty.property_name)) + + def _host_extra_capability_get(session, host_extra_capability_id): - query = model_query(models.ComputeHostExtraCapability, session) - return query.filter_by(id=host_extra_capability_id).first() + query = _host_resource_property_query(session).filter( + models.ComputeHostExtraCapability.id == host_extra_capability_id) + + return query.first() def host_extra_capability_get(host_extra_capability_id): @@ -766,8 +781,10 @@ def host_extra_capability_get(host_extra_capability_id): def _host_extra_capability_get_all_per_host(session, host_id): - query = model_query(models.ComputeHostExtraCapability, session) - return query.filter_by(computehost_id=host_id) + query = _host_resource_property_query(session).filter( + models.ComputeHostExtraCapability.computehost_id == host_id) + + return query def host_extra_capability_get_all_per_host(host_id): @@ -777,6 +794,13 @@ def host_extra_capability_get_all_per_host(host_id): def host_extra_capability_create(values): values = values.copy() + + resource_property = resource_property_get_or_create( + 'physical:host', values.get('property_name')) + + del values['property_name'] + values['property_id'] = resource_property.id + host_extra_capability = models.ComputeHostExtraCapability() host_extra_capability.update(values) @@ -797,7 +821,7 @@ def host_extra_capability_update(host_extra_capability_id, values): session = get_session() with session.begin(): - host_extra_capability = ( + host_extra_capability, _ = ( _host_extra_capability_get(session, host_extra_capability_id)) host_extra_capability.update(values) @@ -809,9 +833,8 @@ def host_extra_capability_update(host_extra_capability_id, values): def host_extra_capability_destroy(host_extra_capability_id): session = get_session() with session.begin(): - host_extra_capability = ( - _host_extra_capability_get(session, - host_extra_capability_id)) + host_extra_capability = _host_extra_capability_get( + session, host_extra_capability_id) if not host_extra_capability: # raise not found error @@ -819,15 +842,16 @@ def host_extra_capability_destroy(host_extra_capability_id): id=host_extra_capability_id, model='ComputeHostExtraCapability') - session.delete(host_extra_capability) + session.delete(host_extra_capability[0]) -def host_extra_capability_get_all_per_name(host_id, capability_name): +def host_extra_capability_get_all_per_name(host_id, property_name): session = get_session() with session.begin(): query = _host_extra_capability_get_all_per_host(session, host_id) - return query.filter_by(capability_name=capability_name).all() + return query.filter( + models.ResourceProperty.property_name == property_name).all() # FloatingIP reservation @@ -1115,3 +1139,101 @@ def floatingip_destroy(floatingip_id): raise db_exc.BlazarDBNotFound(id=floatingip_id, model='FloatingIP') session.delete(floatingip) + + +# Resource Properties + +def _resource_property_get(session, resource_type, property_name): + query = ( + model_query(models.ResourceProperty, session) + .filter_by(resource_type=resource_type) + .filter_by(property_name=property_name)) + + return query.first() + + +def resource_property_get(resource_type, property_name): + return _resource_property_get(get_session(), resource_type, property_name) + + +def resource_properties_list(resource_type): + if resource_type not in RESOURCE_PROPERTY_MODELS: + raise db_exc.BlazarDBResourcePropertiesNotEnabled( + resource_type=resource_type) + + session = get_session() + + with session.begin(): + + resource_model = RESOURCE_PROPERTY_MODELS[resource_type] + query = session.query( + models.ResourceProperty.property_name, + models.ResourceProperty.private, + resource_model.capability_value).join(resource_model).distinct() + + return query.all() + + +def _resource_property_create(session, values): + values = values.copy() + + resource_property = models.ResourceProperty() + resource_property.update(values) + + with session.begin(): + try: + resource_property.save(session=session) + except common_db_exc.DBDuplicateEntry as e: + # raise exception about duplicated columns (e.columns) + raise db_exc.BlazarDBDuplicateEntry( + model=resource_property.__class__.__name__, + columns=e.columns) + + return resource_property_get(values.get('resource_type'), + values.get('property_name')) + + +def resource_property_create(values): + return _resource_property_create(get_session(), values) + + +def resource_property_update(resource_type, property_name, values): + if resource_type not in RESOURCE_PROPERTY_MODELS: + raise db_exc.BlazarDBResourcePropertiesNotEnabled( + resource_type=resource_type) + + values = values.copy() + session = get_session() + + with session.begin(): + resource_property = _resource_property_get( + session, resource_type, property_name) + + if not resource_property: + raise db_exc.BlazarDBInvalidResourceProperty( + property_name=property_name, + resource_type=resource_type) + + resource_property.update(values) + resource_property.save(session=session) + + return resource_property_get(resource_type, property_name) + + +def _resource_property_get_or_create(session, resource_type, property_name): + resource_property = _resource_property_get( + session, resource_type, property_name) + + if resource_property: + return resource_property + else: + rp_values = { + 'resource_type': resource_type, + 'property_name': property_name} + + return resource_property_create(rp_values) + + +def resource_property_get_or_create(resource_type, property_name): + return _resource_property_get_or_create( + get_session(), resource_type, property_name) diff --git a/blazar/db/sqlalchemy/models.py b/blazar/db/sqlalchemy/models.py index 3eccc921..410dd962 100644 --- a/blazar/db/sqlalchemy/models.py +++ b/blazar/db/sqlalchemy/models.py @@ -155,6 +155,23 @@ class Event(mb.BlazarBase): return super(Event, self).to_dict() +class ResourceProperty(mb.BlazarBase): + """Defines an resource property by resource type.""" + + __tablename__ = 'resource_properties' + + id = _id_column() + resource_type = sa.Column(sa.String(255), nullable=False) + property_name = sa.Column(sa.String(255), nullable=False) + private = sa.Column(sa.Boolean, nullable=False, + server_default=sa.false()) + + __table_args__ = (sa.UniqueConstraint('resource_type', 'property_name'),) + + def to_dict(self): + return super(ResourceProperty, self).to_dict() + + class ComputeHostReservation(mb.BlazarBase): """Description @@ -252,7 +269,9 @@ class ComputeHostExtraCapability(mb.BlazarBase): id = _id_column() computehost_id = sa.Column(sa.String(36), sa.ForeignKey('computehosts.id')) - capability_name = sa.Column(sa.String(64), nullable=False) + property_id = sa.Column(sa.String(36), + sa.ForeignKey('resource_properties.id'), + nullable=False) capability_value = sa.Column(MediumText(), nullable=False) def to_dict(self): diff --git a/blazar/manager/oshosts/rpcapi.py b/blazar/manager/oshosts/rpcapi.py index 6aad773c..28e523b4 100644 --- a/blazar/manager/oshosts/rpcapi.py +++ b/blazar/manager/oshosts/rpcapi.py @@ -64,3 +64,12 @@ class ManagerRPCAPI(service.RPCClient): """List all allocations on a specified computehost.""" return self.call('physical:host:get_allocations', host_id=host_id, query=query) + + def list_resource_properties(self, query): + """List resource properties and possible values for computehosts.""" + return self.call('physical:host:list_resource_properties', query=query) + + def update_resource_property(self, property_name, values): + """Update resource property for computehost.""" + return self.call('physical:host:update_resource_property', + property_name=property_name, values=values) diff --git a/blazar/plugins/base.py b/blazar/plugins/base.py index 6ee753e0..74e54def 100644 --- a/blazar/plugins/base.py +++ b/blazar/plugins/base.py @@ -14,7 +14,11 @@ # limitations under the License. import abc +import collections +from blazar import context +from blazar.db import api as db_api +from blazar import policy from oslo_config import cfg from oslo_log import log as logging @@ -98,6 +102,31 @@ class BasePlugin(object, metaclass=abc.ABCMeta): """Wake up resource.""" pass + def list_resource_properties(self, query): + detail = False if not query else query.get('detail', False) + all_properties = False if not query else query.get('all', False) + resource_properties = collections.defaultdict(list) + + include_private = all_properties and policy.enforce( + context.current(), 'admin', {}, do_raise=False) + + for name, private, value in db_api.resource_properties_list( + self.resource_type): + + if include_private or not private: + resource_properties[name].append(value) + + if detail: + return [ + dict(property=k, private=False, values=v) + for k, v in resource_properties.items()] + else: + return [dict(property=k) for k, v in resource_properties.items()] + + def update_resource_property(self, property_name, values): + return db_api.resource_property_update( + self.resource_type, property_name, values) + def before_end(self, resource_id): """Take actions before the end of a lease""" pass diff --git a/blazar/plugins/instances/instance_plugin.py b/blazar/plugins/instances/instance_plugin.py index 14602c4b..01ef9614 100644 --- a/blazar/plugins/instances/instance_plugin.py +++ b/blazar/plugins/instances/instance_plugin.py @@ -809,9 +809,9 @@ class VirtualInstancePlugin(base.BasePlugin, nova.NovaClientWrapper): extra_capabilities = {} raw_extra_capabilities = ( db_api.host_extra_capability_get_all_per_host(host_id)) - for capability in raw_extra_capabilities: - key = capability['capability_name'] - extra_capabilities[key] = capability['capability_value'] + for capability, capability_name in raw_extra_capabilities: + key = capability_name + extra_capabilities[key] = capability.capability_value return extra_capabilities def get(self, host_id): diff --git a/blazar/plugins/oshosts/host_plugin.py b/blazar/plugins/oshosts/host_plugin.py index cfc62a69..7a7ff0e7 100644 --- a/blazar/plugins/oshosts/host_plugin.py +++ b/blazar/plugins/oshosts/host_plugin.py @@ -302,9 +302,9 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper): extra_capabilities = {} raw_extra_capabilities = ( db_api.host_extra_capability_get_all_per_host(host_id)) - for capability in raw_extra_capabilities: - key = capability['capability_name'] - extra_capabilities[key] = capability['capability_value'] + for capability, property_name in raw_extra_capabilities: + key = property_name + extra_capabilities[key] = capability.capability_value return extra_capabilities def get(self, host_id): @@ -383,7 +383,7 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper): raise e for key in extra_capabilities: values = {'computehost_id': host['id'], - 'capability_name': key, + 'property_name': key, 'capability_value': extra_capabilities[key], } try: @@ -396,7 +396,7 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper): host=host['id']) return self.get_computehost(host['id']) - def is_updatable_extra_capability(self, capability): + def is_updatable_extra_capability(self, capability, property_name): reservations = db_utils.get_reservations_by_host_id( capability['computehost_id'], datetime.datetime.utcnow(), datetime.date.max) @@ -413,7 +413,7 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper): # the extra_capability. for requirement in requirements_queries: # A requirement is of the form "key op value" as string - if requirement.split(" ")[0] == capability['capability_name']: + if requirement.split(" ")[0] == property_name: return False return True @@ -428,37 +428,33 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper): new_keys = set(values.keys()) - set(previous_capabilities.keys()) for key in updated_keys: - raw_capability = next(iter( + raw_capability, property_name = next(iter( db_api.host_extra_capability_get_all_per_name(host_id, key))) - capability = { - 'capability_name': key, - 'capability_value': values[key], - } - if self.is_updatable_extra_capability(raw_capability): + capability = {'capability_value': values[key]} + + if self.is_updatable_extra_capability( + raw_capability, property_name): try: db_api.host_extra_capability_update( raw_capability['id'], capability) except (db_ex.BlazarDBException, RuntimeError): - cant_update_extra_capability.append( - raw_capability['capability_name']) + cant_update_extra_capability.append(property_name) else: LOG.info("Capability %s can't be updated because " "existing reservations require it.", - raw_capability['capability_name']) - cant_update_extra_capability.append( - raw_capability['capability_name']) + property_name) + cant_update_extra_capability.append(property_name) for key in new_keys: new_capability = { 'computehost_id': host_id, - 'capability_name': key, + 'property_name': key, 'capability_value': values[key], } try: db_api.host_extra_capability_create(new_capability) except (db_ex.BlazarDBException, RuntimeError): - cant_update_extra_capability.append( - new_capability['capability_name']) + cant_update_extra_capability.append(key) if cant_update_extra_capability: raise manager_ex.CantAddExtraCapability( diff --git a/blazar/policies/oshosts.py b/blazar/policies/oshosts.py index 68a9143e..05b30d03 100644 --- a/blazar/policies/oshosts.py +++ b/blazar/policies/oshosts.py @@ -79,7 +79,30 @@ oshosts_policies = [ 'method': 'GET' } ] - ) + ), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'get_resource_properties', + check_str=base.RULE_ADMIN, + description='Policy rule for Resource Properties API.', + operations=[ + { + 'path': '/{api_version}/os-hosts/resource_properties', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=POLICY_ROOT % 'update_resource_properties', + check_str=base.RULE_ADMIN, + description='Policy rule for Resource Properties API.', + operations=[ + { + 'path': ('/{api_version}/os-hosts/resource_properties/' + '{property_name}'), + 'method': 'PATCH' + } + ] + ), ] diff --git a/blazar/tests/api/v1/oshosts/test_v1_0.py b/blazar/tests/api/v1/oshosts/test_v1_0.py index c54a31fb..8cb215db 100644 --- a/blazar/tests/api/v1/oshosts/test_v1_0.py +++ b/blazar/tests/api/v1/oshosts/test_v1_0.py @@ -100,6 +100,10 @@ class OsHostAPITestCase(tests.TestCase): self.list_allocations = self.patch(service_api.API, 'list_allocations') self.get_allocations = self.patch(service_api.API, 'get_allocations') + self.list_resource_properties = self.patch(service_api.API, + 'list_resource_properties') + self.update_resource_property = self.patch(service_api.API, + 'update_resource_property') def _assert_response(self, actual_resp, expected_status_code, expected_resp_body, key='host', @@ -237,3 +241,20 @@ class OsHostAPITestCase(tests.TestCase): res = c.get('/v1/{0}/allocation?{1}'.format( self.host_id, query_params), headers=self.headers) self._assert_response(res, 200, {}, key='allocation') + + def test_resource_properties_list(self): + with self.app.test_client() as c: + self.list_resource_properties.return_value = [] + res = c.get('/v1/properties', headers=self.headers) + self._assert_response(res, 200, [], key='resource_properties') + + def test_resource_property_update(self): + resource_property = 'fake_property' + resource_property_body = {'private': True} + + with self.app.test_client() as c: + + res = c.patch('/v1/properties/{0}'.format(resource_property), + json=resource_property_body, + headers=self.headers) + self._assert_response(res, 200, {}, 'resource_property') diff --git a/blazar/tests/db/sqlalchemy/test_sqlalchemy_api.py b/blazar/tests/db/sqlalchemy/test_sqlalchemy_api.py index 2e4886c9..596e8350 100644 --- a/blazar/tests/db/sqlalchemy/test_sqlalchemy_api.py +++ b/blazar/tests/db/sqlalchemy/test_sqlalchemy_api.py @@ -193,7 +193,7 @@ def _get_fake_host_extra_capabilities(id=None, computehost_id = _get_fake_random_uuid() return {'id': id, 'computehost_id': computehost_id, - 'capability_name': name, + 'property_name': name, 'capability_value': value} @@ -507,6 +507,12 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase): """Create one host and test extra capability queries.""" # We create a first host, with extra capabilities db_api.host_create(_get_fake_host_values(id=1)) + db_api.resource_property_create(dict( + id='a', resource_type='physical:host', private=False, + property_name='vgpu')) + db_api.resource_property_create(dict( + id='b', resource_type='physical:host', private=False, + property_name='nic_model')) db_api.host_extra_capability_create( _get_fake_host_extra_capabilities(computehost_id=1)) db_api.host_extra_capability_create(_get_fake_host_extra_capabilities( @@ -533,6 +539,20 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase): db_api.host_get_all_by_queries(['nic_model == ACME Model A']) )) + def test_resource_properties_list(self): + """Create one host and test extra capability queries.""" + # We create a first host, with extra capabilities + db_api.host_create(_get_fake_host_values(id=1)) + db_api.resource_property_create(dict( + id='a', resource_type='physical:host', private=False, + property_name='vgpu')) + db_api.host_extra_capability_create( + _get_fake_host_extra_capabilities(computehost_id=1)) + + result = db_api.resource_properties_list('physical:host') + + self.assertListEqual(result, [('vgpu', False, '2')]) + def test_search_for_hosts_by_composed_queries(self): """Create one host and test composed queries.""" @@ -580,9 +600,13 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase): db_api.host_destroy, 2) def test_create_host_extra_capability(self): - result = db_api.host_extra_capability_create( - _get_fake_host_extra_capabilities(id=1)) - self.assertEqual(result['id'], _get_fake_host_values(id='1')['id']) + db_api.resource_property_create(dict( + id='id', resource_type='physical:host', private=False, + property_name='vgpu')) + result, _ = db_api.host_extra_capability_create( + _get_fake_host_extra_capabilities(id=1, name='vgpu')) + + self.assertEqual(result.id, _get_fake_host_values(id='1')['id']) def test_create_duplicated_host_extra_capability(self): db_api.host_extra_capability_create( @@ -594,8 +618,8 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase): def test_get_host_extra_capability_per_id(self): db_api.host_extra_capability_create( _get_fake_host_extra_capabilities(id='1')) - result = db_api.host_extra_capability_get('1') - self.assertEqual('1', result['id']) + result, _ = db_api.host_extra_capability_get('1') + self.assertEqual('1', result.id) def test_host_extra_capability_get_all_per_host(self): db_api.host_extra_capability_create( @@ -609,8 +633,8 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase): db_api.host_extra_capability_create( _get_fake_host_extra_capabilities(id='1')) db_api.host_extra_capability_update('1', {'capability_value': '2'}) - res = db_api.host_extra_capability_get('1') - self.assertEqual('2', res['capability_value']) + res, _ = db_api.host_extra_capability_get('1') + self.assertEqual('2', res.capability_value) def test_delete_host_extra_capability(self): db_api.host_extra_capability_create( diff --git a/blazar/tests/plugins/oshosts/test_physical_host_plugin.py b/blazar/tests/plugins/oshosts/test_physical_host_plugin.py index e6eb4075..2d4cdaa9 100644 --- a/blazar/tests/plugins/oshosts/test_physical_host_plugin.py +++ b/blazar/tests/plugins/oshosts/test_physical_host_plugin.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections import datetime from unittest import mock @@ -81,18 +82,13 @@ class PhysicalHostPluginSetupOnlyTestCase(tests.TestCase): self.fake_phys_plugin.project_domain_name) def test__get_extra_capabilities_with_values(self): + ComputeHostExtraCapability = collections.namedtuple( + 'ComputeHostExtraCapability', + ['id', 'property_id', 'capability_value', 'computehost_id']) self.db_host_extra_capability_get_all_per_host.return_value = [ - {'id': 1, - 'capability_name': 'foo', - 'capability_value': 'bar', - 'other': 'value', - 'computehost_id': 1 - }, - {'id': 2, - 'capability_name': 'buzz', - 'capability_value': 'word', - 'computehost_id': 1 - }] + (ComputeHostExtraCapability(1, 'foo', 'bar', 1), 'foo'), + (ComputeHostExtraCapability(2, 'buzz', 'word', 1), 'buzz')] + res = self.fake_phys_plugin._get_extra_capabilities(1) self.assertEqual({'foo': 'bar', 'buzz': 'word'}, res) @@ -229,7 +225,7 @@ class PhysicalHostPluginTestCase(tests.TestCase): # NOTE(sbauza): 'id' will be pop'd, we need to keep track of it fake_request = fake_host.copy() fake_capa = {'computehost_id': '1', - 'capability_name': 'foo', + 'property_name': 'foo', 'capability_value': 'bar', } self.get_extra_capabilities.return_value = {'foo': 'bar'} @@ -296,11 +292,10 @@ class PhysicalHostPluginTestCase(tests.TestCase): host_values = {'foo': 'baz'} self.db_host_extra_capability_get_all_per_name.return_value = [ - {'id': 'extra_id1', - 'computehost_id': self.fake_host_id, - 'capability_name': 'foo', - 'capability_value': 'bar' - }, + ({'id': 'extra_id1', + 'computehost_id': self.fake_host_id, + 'capability_value': 'bar'}, + 'foo'), ] self.get_reservations_by_host = self.patch( @@ -310,7 +305,7 @@ class PhysicalHostPluginTestCase(tests.TestCase): self.fake_phys_plugin.update_computehost(self.fake_host_id, host_values) self.db_host_extra_capability_update.assert_called_once_with( - 'extra_id1', {'capability_name': 'foo', 'capability_value': 'baz'}) + 'extra_id1', {'capability_value': 'baz'}) def test_update_host_having_issue_when_storing_extra_capability(self): def fake_db_host_extra_capability_update(*args, **kwargs): @@ -320,11 +315,10 @@ class PhysicalHostPluginTestCase(tests.TestCase): self.db_utils, 'get_reservations_by_host_id') self.get_reservations_by_host.return_value = [] self.db_host_extra_capability_get_all_per_name.return_value = [ - {'id': 'extra_id1', - 'computehost_id': self.fake_host_id, - 'capability_name': 'foo', - 'capability_value': 'bar' - }, + ({'id': 'extra_id1', + 'computehost_id': self.fake_host_id, + 'capability_value': 'bar'}, + 'foo'), ] fake = self.db_host_extra_capability_update fake.side_effect = fake_db_host_extra_capability_update @@ -340,7 +334,7 @@ class PhysicalHostPluginTestCase(tests.TestCase): host_values) self.db_host_extra_capability_create.assert_called_once_with({ 'computehost_id': '1', - 'capability_name': 'qux', + 'property_name': 'qux', 'capability_value': 'word' }) @@ -348,11 +342,10 @@ class PhysicalHostPluginTestCase(tests.TestCase): host_values = {'foo': 'buzz'} self.db_host_extra_capability_get_all_per_name.return_value = [ - {'id': 'extra_id1', - 'computehost_id': self.fake_host_id, - 'capability_name': 'foo', - 'capability_value': 'bar' - }, + ({'id': 'extra_id1', + 'computehost_id': self.fake_host_id, + 'capability_value': 'bar'}, + 'foo'), ] fake_phys_reservation = { 'resource_type': plugin.RESOURCE_TYPE, @@ -2388,6 +2381,74 @@ class PhysicalHostPluginTestCase(tests.TestCase): self.fake_phys_plugin._check_params(values) self.assertEqual(values['before_end'], 'default') + def test_list_resource_properties(self): + self.db_list_resource_properties = self.patch( + self.db_api, 'resource_properties_list') + + # Expecting a list of (Reservation, Allocation) + self.db_list_resource_properties.return_value = [ + ('prop1', False, 'aaa'), + ('prop1', False, 'bbb'), + ('prop2', False, 'aaa'), + ('prop2', False, 'aaa'), + ('prop3', True, 'aaa') + ] + + expected = [ + {'property': 'prop1'}, + {'property': 'prop2'} + ] + + ret = self.fake_phys_plugin.list_resource_properties( + query={'detail': False}) + + # Sort returned value to use assertListEqual + ret.sort(key=lambda x: x['property']) + + self.assertListEqual(expected, ret) + self.db_list_resource_properties.assert_called_once_with( + 'physical:host') + + def test_list_resource_properties_with_detail(self): + self.db_list_resource_properties = self.patch( + self.db_api, 'resource_properties_list') + + # Expecting a list of (Reservation, Allocation) + self.db_list_resource_properties.return_value = [ + ('prop1', False, 'aaa'), + ('prop1', False, 'bbb'), + ('prop2', False, 'ccc'), + ('prop3', True, 'aaa') + ] + + expected = [ + {'property': 'prop1', 'private': False, 'values': ['aaa', 'bbb']}, + {'property': 'prop2', 'private': False, 'values': ['ccc']} + ] + + ret = self.fake_phys_plugin.list_resource_properties( + query={'detail': True}) + + # Sort returned value to use assertListEqual + ret.sort(key=lambda x: x['property']) + + self.assertListEqual(expected, ret) + self.db_list_resource_properties.assert_called_once_with( + 'physical:host') + + def test_update_resource_property(self): + resource_property_values = { + 'resource_type': 'physical:host', + 'private': False} + + db_resource_property_update = self.patch( + self.db_api, 'resource_property_update') + + self.fake_phys_plugin.update_resource_property( + 'foo', resource_property_values) + db_resource_property_update.assert_called_once_with( + 'physical:host', 'foo', resource_property_values) + class PhysicalHostMonitorPluginTestCase(tests.TestCase): diff --git a/doc/api_samples/hosts/host-property-list-detail.json b/doc/api_samples/hosts/host-property-list-detail.json new file mode 100644 index 00000000..ac386c8a --- /dev/null +++ b/doc/api_samples/hosts/host-property-list-detail.json @@ -0,0 +1,12 @@ +{ + "resource_properties": [ + { + "property": "gpu", + "private": false, + "values": [ + "True", + "False" + ] + } + ] +} diff --git a/doc/api_samples/hosts/host-property-list.json b/doc/api_samples/hosts/host-property-list.json new file mode 100644 index 00000000..713c41db --- /dev/null +++ b/doc/api_samples/hosts/host-property-list.json @@ -0,0 +1,7 @@ +{ + "resource_properties": [ + { + "property": "gpu" + } + ] +} diff --git a/doc/api_samples/hosts/host-property-update-res.json b/doc/api_samples/hosts/host-property-update-res.json new file mode 100644 index 00000000..1464df62 --- /dev/null +++ b/doc/api_samples/hosts/host-property-update-res.json @@ -0,0 +1,10 @@ +{ + "resource_property": { + "created_at": "2021-12-15T19:38:16.000000", + "updated_at": "2021-12-21T21:37:19.000000", + "id": "19e48cd0-042d-4044-a69a-d44d672849b5", + "resource_type": "physical:host", + "property_name": "gpu", + "private": true + } +} diff --git a/doc/api_samples/hosts/host-property-update.json b/doc/api_samples/hosts/host-property-update.json new file mode 100644 index 00000000..e72dc544 --- /dev/null +++ b/doc/api_samples/hosts/host-property-update.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/doc/source/cli/host-reservation.rst b/doc/source/cli/host-reservation.rst index 43c5c05f..8a874063 100644 --- a/doc/source/cli/host-reservation.rst +++ b/doc/source/cli/host-reservation.rst @@ -51,6 +51,49 @@ Result: .. +3. (Optional) Add extra capabilities to host to add other properties. These can + be used to filter hosts when creating a reservation. + +.. sourcecode:: console + + # Using the blazar CLI + blazar host-update --extra gpu=True compute-1 + + # Using the openstack CLI + openstack reservation host set --extra gpu=True compute-1 + +.. + +Result: + +.. sourcecode:: console + + Updated host: compute-1 + +.. + +Multiple ``--extra`` parameters can be included. They can also be specified in +``host-create``. Properties can be made private or public. By default, they +are public. + +.. sourcecode:: console + + # Using the blazar CLI + blazar host-capability-update gpu --private + + # Using the openstack CLI + openstack reservation host capability update gpu --private + +.. + +Result: + +.. sourcecode:: console + + Updated host extra capability: gpu + +.. + 2. Create a lease ----------------- @@ -128,6 +171,103 @@ Result: .. +3. Alternatively, create leases with resource properties. + First list properties. + +.. sourcecode:: console + + # Using the blazar CLI + blazar host-capability-list + + # Using the openstack CLI + openstack reservation host capability list + +.. + +Result: + +.. sourcecode:: console + + +----------+ + | property | + +----------+ + | gpu | + +----------+ + +.. + +List possible values for a property + +.. sourcecode:: console + + # Using the blazar CLI + blazar host-capability-show gpu + + # Using the openstack CLI + openstack reservation host capability show gpu + +.. + +Result: + +.. sourcecode:: console + + +-------------------+-------+ + | Field | Value | + +-------------------+-------+ + | capability_values | True | + | | False | + | private | False | + | property | gpu | + +-------------------+-------+ + +.. + +Create a lease. + +.. sourcecode:: console + + # Using the blazar CLI + blazar lease-create --physical-reservation min=1,max=1,resource_properties='["=", "$gpu", "True"]' --start-date "2020-06-08 12:00" --end-date "2020-06-09 12:00" lease-1 + + # Using the openstack CLI + openstack reservation lease create --reservation resource_type=physical:host,min=1,max=1,resource_properties='[">=", "$vcpus", "2"]' --start-date "2020-06-08 12:00" --end-date "2020-06-09 12:00" lease-1 + +.. + +Result: + +.. sourcecode:: console + + +---------------+---------------------------------------------------------------------------------------------------------------------------------------------+ + | Field | Value | + +---------------+---------------------------------------------------------------------------------------------------------------------------------------------+ + | action | | + | created_at | 2020-06-08 02:43:40 | + | end_date | 2020-06-09T12:00:00.000000 | + | events | {"status": "UNDONE", "lease_id": "6638c31e-f6c8-4982-9b98-d2ca0a8cb646", "event_type": "before_end_lease", "created_at": "2020-06-08 | + | | 02:43:40", "updated_at": null, "time": "2020-06-08T12:00:00.000000", "id": "420caf25-dba5-4ac3-b377-50503ea5c886"} | + | | {"status": "UNDONE", "lease_id": "6638c31e-f6c8-4982-9b98-d2ca0a8cb646", "event_type": "start_lease", "created_at": "2020-06-08 02:43:40", | + | | "updated_at": null, "time": "2020-06-08T12:00:00.000000", "id": "b9696139-55a1-472d-baff-5fade2c15243"} | + | | {"status": "UNDONE", "lease_id": "6638c31e-f6c8-4982-9b98-d2ca0a8cb646", "event_type": "end_lease", "created_at": "2020-06-08 02:43:40", | + | | "updated_at": null, "time": "2020-06-09T12:00:00.000000", "id": "ff9e6f52-db50-475a-81f1-e6897fdc769d"} | + | id | 6638c31e-f6c8-4982-9b98-d2ca0a8cb646 | + | name | lease-1 | + | project_id | 4527fa2138564bd4933887526d01bc95 | + | reservations | {"status": "pending", "lease_id": "6638c31e-f6c8-4982-9b98-d2ca0a8cb646", "resource_id": "8", "max": 1, "created_at": "2020-06-08 | + | | 02:43:40", "min": 1, "updated_at": null, "hypervisor_properties": "", "resource_properties": "[\"=\", \"$gpu\", \"True\"]", "id": | + | | "4d3dd68f-0e3f-4f6b-bef7-617525c74ccb", "resource_type": "physical:host"} | + | start_date | 2020-06-08T12:00:00.000000 | + | status | | + | status_reason | | + | trust_id | ba4c321878d84d839488216de0a9e945 | + | updated_at | | + | user_id | | + +---------------+---------------------------------------------------------------------------------------------------------------------------------------------+ + +.. + + 3. Use the leased resources --------------------------- diff --git a/releasenotes/notes/resource-property-discovery-42df197a1a49bd76.yaml b/releasenotes/notes/resource-property-discovery-42df197a1a49bd76.yaml new file mode 100644 index 00000000..90e57a14 --- /dev/null +++ b/releasenotes/notes/resource-property-discovery-42df197a1a49bd76.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Adds a host resource property discovery API, which allows users to + enumerate what properties are available, and current property values. + Properties can be made private by operators, which filters them from the + public list.