Merge "Add resource properties discovery API"
This commit is contained in:
commit
e1c16e6b55
|
@ -128,6 +128,7 @@ Request
|
||||||
.. rest_parameters:: parameters.yaml
|
.. rest_parameters:: parameters.yaml
|
||||||
|
|
||||||
- host_id: host_id_path
|
- host_id: host_id_path
|
||||||
|
- private: property_private
|
||||||
|
|
||||||
Response
|
Response
|
||||||
--------
|
--------
|
||||||
|
@ -328,3 +329,90 @@ Response
|
||||||
|
|
||||||
.. literalinclude:: ../../../doc/api_samples/hosts/allocation-get-resp.json
|
.. literalinclude:: ../../../doc/api_samples/hosts/allocation-get-resp.json
|
||||||
:language: javascript
|
: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
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,12 @@ lease_id_path:
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
property_name:
|
||||||
|
description: |
|
||||||
|
The name of the property.
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
|
||||||
# variables in query
|
# variables in query
|
||||||
|
@ -57,6 +63,19 @@ allocation_reservation_id_query:
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
type: string
|
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
|
# variables in body
|
||||||
|
@ -406,6 +425,13 @@ leases:
|
||||||
in: body
|
in: body
|
||||||
required: true
|
required: true
|
||||||
type: array
|
type: array
|
||||||
|
property_private:
|
||||||
|
description: |
|
||||||
|
Whether the property is private.
|
||||||
|
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
reservation:
|
reservation:
|
||||||
description: |
|
description: |
|
||||||
A ``reservation`` object.
|
A ``reservation`` object.
|
||||||
|
@ -627,6 +653,69 @@ reservations_optional:
|
||||||
in: body
|
in: body
|
||||||
required: false
|
required: false
|
||||||
type: array
|
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:
|
updated_at:
|
||||||
description: |
|
description: |
|
||||||
The date and time when the object was updated.
|
The date and time when the object was updated.
|
||||||
|
|
|
@ -86,3 +86,14 @@ class API(object):
|
||||||
:type query: dict
|
:type query: dict
|
||||||
"""
|
"""
|
||||||
return self.manager_rpcapi.get_allocations(host_id, query)
|
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)
|
||||||
|
|
|
@ -79,3 +79,17 @@ def allocations_list(req, query):
|
||||||
def allocations_get(req, host_id, query):
|
def allocations_get(req, host_id, query):
|
||||||
"""List all allocations on a specific host."""
|
"""List all allocations on a specific host."""
|
||||||
return api_utils.render(allocation=_api.get_allocations(host_id, query))
|
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/<property_name>')
|
||||||
|
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))
|
||||||
|
|
|
@ -53,6 +53,9 @@ class Rest(flask.Blueprint):
|
||||||
def put(self, rule, status_code=200):
|
def put(self, rule, status_code=200):
|
||||||
return self._mroute('PUT', rule, status_code)
|
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):
|
def delete(self, rule, status_code=204):
|
||||||
return self._mroute('DELETE', rule, status_code)
|
return self._mroute('DELETE', rule, status_code)
|
||||||
|
|
||||||
|
@ -79,7 +82,7 @@ class Rest(flask.Blueprint):
|
||||||
if status:
|
if status:
|
||||||
flask.request.status_code = 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()
|
kwargs['data'] = request_data()
|
||||||
|
|
||||||
if flask.request.endpoint in self.routes_with_query_support:
|
if flask.request.endpoint in self.routes_with_query_support:
|
||||||
|
|
|
@ -385,13 +385,11 @@ def host_extra_capability_create(values):
|
||||||
return IMPL.host_extra_capability_create(values)
|
return IMPL.host_extra_capability_create(values)
|
||||||
|
|
||||||
|
|
||||||
@to_dict
|
|
||||||
def host_extra_capability_get(host_extra_capability_id):
|
def host_extra_capability_get(host_extra_capability_id):
|
||||||
"""Return a specific Host Extracapability."""
|
"""Return a specific Host Extracapability."""
|
||||||
return IMPL.host_extra_capability_get(host_extra_capability_id)
|
return IMPL.host_extra_capability_get(host_extra_capability_id)
|
||||||
|
|
||||||
|
|
||||||
@to_dict
|
|
||||||
def host_extra_capability_get_all_per_host(host_id):
|
def host_extra_capability_get_all_per_host(host_id):
|
||||||
"""Return all extra_capabilities belonging to a specific Compute host."""
|
"""Return all extra_capabilities belonging to a specific Compute host."""
|
||||||
return IMPL.host_extra_capability_get_all_per_host(host_id)
|
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,
|
def host_extra_capability_get_all_per_name(host_id,
|
||||||
extra_capability_name):
|
extra_capability_name):
|
||||||
return IMPL.host_extra_capability_get_all_per_name(host_id,
|
return IMPL.host_extra_capability_get_all_per_name(host_id,
|
||||||
|
|
||||||
extra_capability_name)
|
extra_capability_name)
|
||||||
|
|
||||||
|
|
||||||
|
@ -525,3 +522,17 @@ def reservable_fip_get_all_by_queries(queries):
|
||||||
def floatingip_destroy(floatingip_id):
|
def floatingip_destroy(floatingip_id):
|
||||||
"""Delete specific floating ip."""
|
"""Delete specific floating ip."""
|
||||||
IMPL.floatingip_destroy(floatingip_id)
|
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)
|
||||||
|
|
|
@ -40,3 +40,12 @@ class BlazarDBInvalidFilter(BlazarDBException):
|
||||||
|
|
||||||
class BlazarDBInvalidFilterOperator(BlazarDBException):
|
class BlazarDBInvalidFilterOperator(BlazarDBException):
|
||||||
msg_fmt = _('%(filter_operator)s is invalid')
|
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.')
|
||||||
|
|
|
@ -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')
|
|
@ -29,6 +29,9 @@ from blazar.db import exceptions as db_exc
|
||||||
from blazar.db.sqlalchemy import facade_wrapper
|
from blazar.db.sqlalchemy import facade_wrapper
|
||||||
from blazar.db.sqlalchemy import models
|
from blazar.db.sqlalchemy import models
|
||||||
|
|
||||||
|
RESOURCE_PROPERTY_MODELS = {
|
||||||
|
'physical:host': models.ComputeHostExtraCapability,
|
||||||
|
}
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -663,26 +666,28 @@ def host_get_all_by_queries(queries):
|
||||||
|
|
||||||
hosts_query = hosts_query.filter(filt)
|
hosts_query = hosts_query.filter(filt)
|
||||||
else:
|
else:
|
||||||
# looking for extra capabilities matches
|
# looking for resource properties matches
|
||||||
extra_filter = model_query(
|
extra_filter = (
|
||||||
models.ComputeHostExtraCapability, get_session()
|
_host_resource_property_query(get_session())
|
||||||
).filter(models.ComputeHostExtraCapability.capability_name == key
|
.filter(models.ResourceProperty.property_name == key)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not extra_filter:
|
if not extra_filter:
|
||||||
raise db_exc.BlazarDBNotFound(
|
raise db_exc.BlazarDBNotFound(
|
||||||
id=key, model='ComputeHostExtraCapability')
|
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):
|
if op in oper and oper[op][1](host.capability_value, value):
|
||||||
hosts.append(host.computehost_id)
|
hosts.append(host.computehost_id)
|
||||||
elif op not in oper:
|
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)
|
raise NotImplementedError(msg % op)
|
||||||
|
|
||||||
# We must also avoid selecting any host which doesn't have the
|
# We must also avoid selecting any host which doesn't have the
|
||||||
# extra capability present.
|
# extra capability present.
|
||||||
all_hosts = [h.id for h in hosts_query.all()]
|
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]
|
hosts += [h for h in all_hosts if h not in extra_filter_hosts]
|
||||||
|
|
||||||
return hosts_query.filter(~models.ComputeHost.id.in_(hosts)).all()
|
return hosts_query.filter(~models.ComputeHost.id.in_(hosts)).all()
|
||||||
|
@ -755,9 +760,19 @@ def host_destroy(host_id):
|
||||||
|
|
||||||
|
|
||||||
# ComputeHostExtraCapability
|
# 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):
|
def _host_extra_capability_get(session, host_extra_capability_id):
|
||||||
query = model_query(models.ComputeHostExtraCapability, session)
|
query = _host_resource_property_query(session).filter(
|
||||||
return query.filter_by(id=host_extra_capability_id).first()
|
models.ComputeHostExtraCapability.id == host_extra_capability_id)
|
||||||
|
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
|
||||||
def host_extra_capability_get(host_extra_capability_id):
|
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):
|
def _host_extra_capability_get_all_per_host(session, host_id):
|
||||||
query = model_query(models.ComputeHostExtraCapability, session)
|
query = _host_resource_property_query(session).filter(
|
||||||
return query.filter_by(computehost_id=host_id)
|
models.ComputeHostExtraCapability.computehost_id == host_id)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
def host_extra_capability_get_all_per_host(host_id):
|
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):
|
def host_extra_capability_create(values):
|
||||||
values = values.copy()
|
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 = models.ComputeHostExtraCapability()
|
||||||
host_extra_capability.update(values)
|
host_extra_capability.update(values)
|
||||||
|
|
||||||
|
@ -797,7 +821,7 @@ def host_extra_capability_update(host_extra_capability_id, values):
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
|
||||||
with session.begin():
|
with session.begin():
|
||||||
host_extra_capability = (
|
host_extra_capability, _ = (
|
||||||
_host_extra_capability_get(session,
|
_host_extra_capability_get(session,
|
||||||
host_extra_capability_id))
|
host_extra_capability_id))
|
||||||
host_extra_capability.update(values)
|
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):
|
def host_extra_capability_destroy(host_extra_capability_id):
|
||||||
session = get_session()
|
session = get_session()
|
||||||
with session.begin():
|
with session.begin():
|
||||||
host_extra_capability = (
|
host_extra_capability = _host_extra_capability_get(
|
||||||
_host_extra_capability_get(session,
|
session, host_extra_capability_id)
|
||||||
host_extra_capability_id))
|
|
||||||
|
|
||||||
if not host_extra_capability:
|
if not host_extra_capability:
|
||||||
# raise not found error
|
# raise not found error
|
||||||
|
@ -819,15 +842,16 @@ def host_extra_capability_destroy(host_extra_capability_id):
|
||||||
id=host_extra_capability_id,
|
id=host_extra_capability_id,
|
||||||
model='ComputeHostExtraCapability')
|
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()
|
session = get_session()
|
||||||
|
|
||||||
with session.begin():
|
with session.begin():
|
||||||
query = _host_extra_capability_get_all_per_host(session, host_id)
|
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
|
# FloatingIP reservation
|
||||||
|
@ -1115,3 +1139,101 @@ def floatingip_destroy(floatingip_id):
|
||||||
raise db_exc.BlazarDBNotFound(id=floatingip_id, model='FloatingIP')
|
raise db_exc.BlazarDBNotFound(id=floatingip_id, model='FloatingIP')
|
||||||
|
|
||||||
session.delete(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)
|
||||||
|
|
|
@ -155,6 +155,23 @@ class Event(mb.BlazarBase):
|
||||||
return super(Event, self).to_dict()
|
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):
|
class ComputeHostReservation(mb.BlazarBase):
|
||||||
"""Description
|
"""Description
|
||||||
|
|
||||||
|
@ -252,7 +269,9 @@ class ComputeHostExtraCapability(mb.BlazarBase):
|
||||||
|
|
||||||
id = _id_column()
|
id = _id_column()
|
||||||
computehost_id = sa.Column(sa.String(36), sa.ForeignKey('computehosts.id'))
|
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)
|
capability_value = sa.Column(MediumText(), nullable=False)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
|
|
@ -64,3 +64,12 @@ class ManagerRPCAPI(service.RPCClient):
|
||||||
"""List all allocations on a specified computehost."""
|
"""List all allocations on a specified computehost."""
|
||||||
return self.call('physical:host:get_allocations',
|
return self.call('physical:host:get_allocations',
|
||||||
host_id=host_id, query=query)
|
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)
|
||||||
|
|
|
@ -14,7 +14,11 @@
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import abc
|
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_config import cfg
|
||||||
from oslo_log import log as logging
|
from oslo_log import log as logging
|
||||||
|
|
||||||
|
@ -98,6 +102,31 @@ class BasePlugin(object, metaclass=abc.ABCMeta):
|
||||||
"""Wake up resource."""
|
"""Wake up resource."""
|
||||||
pass
|
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):
|
def before_end(self, resource_id):
|
||||||
"""Take actions before the end of a lease"""
|
"""Take actions before the end of a lease"""
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -809,9 +809,9 @@ class VirtualInstancePlugin(base.BasePlugin, nova.NovaClientWrapper):
|
||||||
extra_capabilities = {}
|
extra_capabilities = {}
|
||||||
raw_extra_capabilities = (
|
raw_extra_capabilities = (
|
||||||
db_api.host_extra_capability_get_all_per_host(host_id))
|
db_api.host_extra_capability_get_all_per_host(host_id))
|
||||||
for capability in raw_extra_capabilities:
|
for capability, capability_name in raw_extra_capabilities:
|
||||||
key = capability['capability_name']
|
key = capability_name
|
||||||
extra_capabilities[key] = capability['capability_value']
|
extra_capabilities[key] = capability.capability_value
|
||||||
return extra_capabilities
|
return extra_capabilities
|
||||||
|
|
||||||
def get(self, host_id):
|
def get(self, host_id):
|
||||||
|
|
|
@ -302,9 +302,9 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper):
|
||||||
extra_capabilities = {}
|
extra_capabilities = {}
|
||||||
raw_extra_capabilities = (
|
raw_extra_capabilities = (
|
||||||
db_api.host_extra_capability_get_all_per_host(host_id))
|
db_api.host_extra_capability_get_all_per_host(host_id))
|
||||||
for capability in raw_extra_capabilities:
|
for capability, property_name in raw_extra_capabilities:
|
||||||
key = capability['capability_name']
|
key = property_name
|
||||||
extra_capabilities[key] = capability['capability_value']
|
extra_capabilities[key] = capability.capability_value
|
||||||
return extra_capabilities
|
return extra_capabilities
|
||||||
|
|
||||||
def get(self, host_id):
|
def get(self, host_id):
|
||||||
|
@ -383,7 +383,7 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper):
|
||||||
raise e
|
raise e
|
||||||
for key in extra_capabilities:
|
for key in extra_capabilities:
|
||||||
values = {'computehost_id': host['id'],
|
values = {'computehost_id': host['id'],
|
||||||
'capability_name': key,
|
'property_name': key,
|
||||||
'capability_value': extra_capabilities[key],
|
'capability_value': extra_capabilities[key],
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
@ -396,7 +396,7 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper):
|
||||||
host=host['id'])
|
host=host['id'])
|
||||||
return self.get_computehost(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(
|
reservations = db_utils.get_reservations_by_host_id(
|
||||||
capability['computehost_id'], datetime.datetime.utcnow(),
|
capability['computehost_id'], datetime.datetime.utcnow(),
|
||||||
datetime.date.max)
|
datetime.date.max)
|
||||||
|
@ -413,7 +413,7 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper):
|
||||||
# the extra_capability.
|
# the extra_capability.
|
||||||
for requirement in requirements_queries:
|
for requirement in requirements_queries:
|
||||||
# A requirement is of the form "key op value" as string
|
# 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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -428,37 +428,33 @@ class PhysicalHostPlugin(base.BasePlugin, nova.NovaClientWrapper):
|
||||||
new_keys = set(values.keys()) - set(previous_capabilities.keys())
|
new_keys = set(values.keys()) - set(previous_capabilities.keys())
|
||||||
|
|
||||||
for key in updated_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)))
|
db_api.host_extra_capability_get_all_per_name(host_id, key)))
|
||||||
capability = {
|
capability = {'capability_value': values[key]}
|
||||||
'capability_name': key,
|
|
||||||
'capability_value': values[key],
|
if self.is_updatable_extra_capability(
|
||||||
}
|
raw_capability, property_name):
|
||||||
if self.is_updatable_extra_capability(raw_capability):
|
|
||||||
try:
|
try:
|
||||||
db_api.host_extra_capability_update(
|
db_api.host_extra_capability_update(
|
||||||
raw_capability['id'], capability)
|
raw_capability['id'], capability)
|
||||||
except (db_ex.BlazarDBException, RuntimeError):
|
except (db_ex.BlazarDBException, RuntimeError):
|
||||||
cant_update_extra_capability.append(
|
cant_update_extra_capability.append(property_name)
|
||||||
raw_capability['capability_name'])
|
|
||||||
else:
|
else:
|
||||||
LOG.info("Capability %s can't be updated because "
|
LOG.info("Capability %s can't be updated because "
|
||||||
"existing reservations require it.",
|
"existing reservations require it.",
|
||||||
raw_capability['capability_name'])
|
property_name)
|
||||||
cant_update_extra_capability.append(
|
cant_update_extra_capability.append(property_name)
|
||||||
raw_capability['capability_name'])
|
|
||||||
|
|
||||||
for key in new_keys:
|
for key in new_keys:
|
||||||
new_capability = {
|
new_capability = {
|
||||||
'computehost_id': host_id,
|
'computehost_id': host_id,
|
||||||
'capability_name': key,
|
'property_name': key,
|
||||||
'capability_value': values[key],
|
'capability_value': values[key],
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
db_api.host_extra_capability_create(new_capability)
|
db_api.host_extra_capability_create(new_capability)
|
||||||
except (db_ex.BlazarDBException, RuntimeError):
|
except (db_ex.BlazarDBException, RuntimeError):
|
||||||
cant_update_extra_capability.append(
|
cant_update_extra_capability.append(key)
|
||||||
new_capability['capability_name'])
|
|
||||||
|
|
||||||
if cant_update_extra_capability:
|
if cant_update_extra_capability:
|
||||||
raise manager_ex.CantAddExtraCapability(
|
raise manager_ex.CantAddExtraCapability(
|
||||||
|
|
|
@ -79,7 +79,30 @@ oshosts_policies = [
|
||||||
'method': 'GET'
|
'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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -100,6 +100,10 @@ class OsHostAPITestCase(tests.TestCase):
|
||||||
self.list_allocations = self.patch(service_api.API,
|
self.list_allocations = self.patch(service_api.API,
|
||||||
'list_allocations')
|
'list_allocations')
|
||||||
self.get_allocations = self.patch(service_api.API, 'get_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,
|
def _assert_response(self, actual_resp, expected_status_code,
|
||||||
expected_resp_body, key='host',
|
expected_resp_body, key='host',
|
||||||
|
@ -237,3 +241,20 @@ class OsHostAPITestCase(tests.TestCase):
|
||||||
res = c.get('/v1/{0}/allocation?{1}'.format(
|
res = c.get('/v1/{0}/allocation?{1}'.format(
|
||||||
self.host_id, query_params), headers=self.headers)
|
self.host_id, query_params), headers=self.headers)
|
||||||
self._assert_response(res, 200, {}, key='allocation')
|
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')
|
||||||
|
|
|
@ -193,7 +193,7 @@ def _get_fake_host_extra_capabilities(id=None,
|
||||||
computehost_id = _get_fake_random_uuid()
|
computehost_id = _get_fake_random_uuid()
|
||||||
return {'id': id,
|
return {'id': id,
|
||||||
'computehost_id': computehost_id,
|
'computehost_id': computehost_id,
|
||||||
'capability_name': name,
|
'property_name': name,
|
||||||
'capability_value': value}
|
'capability_value': value}
|
||||||
|
|
||||||
|
|
||||||
|
@ -507,6 +507,12 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase):
|
||||||
"""Create one host and test extra capability queries."""
|
"""Create one host and test extra capability queries."""
|
||||||
# We create a first host, with extra capabilities
|
# We create a first host, with extra capabilities
|
||||||
db_api.host_create(_get_fake_host_values(id=1))
|
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(
|
db_api.host_extra_capability_create(
|
||||||
_get_fake_host_extra_capabilities(computehost_id=1))
|
_get_fake_host_extra_capabilities(computehost_id=1))
|
||||||
db_api.host_extra_capability_create(_get_fake_host_extra_capabilities(
|
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'])
|
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):
|
def test_search_for_hosts_by_composed_queries(self):
|
||||||
"""Create one host and test composed queries."""
|
"""Create one host and test composed queries."""
|
||||||
|
|
||||||
|
@ -580,9 +600,13 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase):
|
||||||
db_api.host_destroy, 2)
|
db_api.host_destroy, 2)
|
||||||
|
|
||||||
def test_create_host_extra_capability(self):
|
def test_create_host_extra_capability(self):
|
||||||
result = db_api.host_extra_capability_create(
|
db_api.resource_property_create(dict(
|
||||||
_get_fake_host_extra_capabilities(id=1))
|
id='id', resource_type='physical:host', private=False,
|
||||||
self.assertEqual(result['id'], _get_fake_host_values(id='1')['id'])
|
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):
|
def test_create_duplicated_host_extra_capability(self):
|
||||||
db_api.host_extra_capability_create(
|
db_api.host_extra_capability_create(
|
||||||
|
@ -594,8 +618,8 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase):
|
||||||
def test_get_host_extra_capability_per_id(self):
|
def test_get_host_extra_capability_per_id(self):
|
||||||
db_api.host_extra_capability_create(
|
db_api.host_extra_capability_create(
|
||||||
_get_fake_host_extra_capabilities(id='1'))
|
_get_fake_host_extra_capabilities(id='1'))
|
||||||
result = db_api.host_extra_capability_get('1')
|
result, _ = db_api.host_extra_capability_get('1')
|
||||||
self.assertEqual('1', result['id'])
|
self.assertEqual('1', result.id)
|
||||||
|
|
||||||
def test_host_extra_capability_get_all_per_host(self):
|
def test_host_extra_capability_get_all_per_host(self):
|
||||||
db_api.host_extra_capability_create(
|
db_api.host_extra_capability_create(
|
||||||
|
@ -609,8 +633,8 @@ class SQLAlchemyDBApiTestCase(tests.DBTestCase):
|
||||||
db_api.host_extra_capability_create(
|
db_api.host_extra_capability_create(
|
||||||
_get_fake_host_extra_capabilities(id='1'))
|
_get_fake_host_extra_capabilities(id='1'))
|
||||||
db_api.host_extra_capability_update('1', {'capability_value': '2'})
|
db_api.host_extra_capability_update('1', {'capability_value': '2'})
|
||||||
res = db_api.host_extra_capability_get('1')
|
res, _ = db_api.host_extra_capability_get('1')
|
||||||
self.assertEqual('2', res['capability_value'])
|
self.assertEqual('2', res.capability_value)
|
||||||
|
|
||||||
def test_delete_host_extra_capability(self):
|
def test_delete_host_extra_capability(self):
|
||||||
db_api.host_extra_capability_create(
|
db_api.host_extra_capability_create(
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
@ -81,18 +82,13 @@ class PhysicalHostPluginSetupOnlyTestCase(tests.TestCase):
|
||||||
self.fake_phys_plugin.project_domain_name)
|
self.fake_phys_plugin.project_domain_name)
|
||||||
|
|
||||||
def test__get_extra_capabilities_with_values(self):
|
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 = [
|
self.db_host_extra_capability_get_all_per_host.return_value = [
|
||||||
{'id': 1,
|
(ComputeHostExtraCapability(1, 'foo', 'bar', 1), 'foo'),
|
||||||
'capability_name': 'foo',
|
(ComputeHostExtraCapability(2, 'buzz', 'word', 1), 'buzz')]
|
||||||
'capability_value': 'bar',
|
|
||||||
'other': 'value',
|
|
||||||
'computehost_id': 1
|
|
||||||
},
|
|
||||||
{'id': 2,
|
|
||||||
'capability_name': 'buzz',
|
|
||||||
'capability_value': 'word',
|
|
||||||
'computehost_id': 1
|
|
||||||
}]
|
|
||||||
res = self.fake_phys_plugin._get_extra_capabilities(1)
|
res = self.fake_phys_plugin._get_extra_capabilities(1)
|
||||||
self.assertEqual({'foo': 'bar', 'buzz': 'word'}, res)
|
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
|
# NOTE(sbauza): 'id' will be pop'd, we need to keep track of it
|
||||||
fake_request = fake_host.copy()
|
fake_request = fake_host.copy()
|
||||||
fake_capa = {'computehost_id': '1',
|
fake_capa = {'computehost_id': '1',
|
||||||
'capability_name': 'foo',
|
'property_name': 'foo',
|
||||||
'capability_value': 'bar',
|
'capability_value': 'bar',
|
||||||
}
|
}
|
||||||
self.get_extra_capabilities.return_value = {'foo': 'bar'}
|
self.get_extra_capabilities.return_value = {'foo': 'bar'}
|
||||||
|
@ -296,11 +292,10 @@ class PhysicalHostPluginTestCase(tests.TestCase):
|
||||||
host_values = {'foo': 'baz'}
|
host_values = {'foo': 'baz'}
|
||||||
|
|
||||||
self.db_host_extra_capability_get_all_per_name.return_value = [
|
self.db_host_extra_capability_get_all_per_name.return_value = [
|
||||||
{'id': 'extra_id1',
|
({'id': 'extra_id1',
|
||||||
'computehost_id': self.fake_host_id,
|
'computehost_id': self.fake_host_id,
|
||||||
'capability_name': 'foo',
|
'capability_value': 'bar'},
|
||||||
'capability_value': 'bar'
|
'foo'),
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
self.get_reservations_by_host = self.patch(
|
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,
|
self.fake_phys_plugin.update_computehost(self.fake_host_id,
|
||||||
host_values)
|
host_values)
|
||||||
self.db_host_extra_capability_update.assert_called_once_with(
|
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 test_update_host_having_issue_when_storing_extra_capability(self):
|
||||||
def fake_db_host_extra_capability_update(*args, **kwargs):
|
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.db_utils, 'get_reservations_by_host_id')
|
||||||
self.get_reservations_by_host.return_value = []
|
self.get_reservations_by_host.return_value = []
|
||||||
self.db_host_extra_capability_get_all_per_name.return_value = [
|
self.db_host_extra_capability_get_all_per_name.return_value = [
|
||||||
{'id': 'extra_id1',
|
({'id': 'extra_id1',
|
||||||
'computehost_id': self.fake_host_id,
|
'computehost_id': self.fake_host_id,
|
||||||
'capability_name': 'foo',
|
'capability_value': 'bar'},
|
||||||
'capability_value': 'bar'
|
'foo'),
|
||||||
},
|
|
||||||
]
|
]
|
||||||
fake = self.db_host_extra_capability_update
|
fake = self.db_host_extra_capability_update
|
||||||
fake.side_effect = fake_db_host_extra_capability_update
|
fake.side_effect = fake_db_host_extra_capability_update
|
||||||
|
@ -340,7 +334,7 @@ class PhysicalHostPluginTestCase(tests.TestCase):
|
||||||
host_values)
|
host_values)
|
||||||
self.db_host_extra_capability_create.assert_called_once_with({
|
self.db_host_extra_capability_create.assert_called_once_with({
|
||||||
'computehost_id': '1',
|
'computehost_id': '1',
|
||||||
'capability_name': 'qux',
|
'property_name': 'qux',
|
||||||
'capability_value': 'word'
|
'capability_value': 'word'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -348,11 +342,10 @@ class PhysicalHostPluginTestCase(tests.TestCase):
|
||||||
host_values = {'foo': 'buzz'}
|
host_values = {'foo': 'buzz'}
|
||||||
|
|
||||||
self.db_host_extra_capability_get_all_per_name.return_value = [
|
self.db_host_extra_capability_get_all_per_name.return_value = [
|
||||||
{'id': 'extra_id1',
|
({'id': 'extra_id1',
|
||||||
'computehost_id': self.fake_host_id,
|
'computehost_id': self.fake_host_id,
|
||||||
'capability_name': 'foo',
|
'capability_value': 'bar'},
|
||||||
'capability_value': 'bar'
|
'foo'),
|
||||||
},
|
|
||||||
]
|
]
|
||||||
fake_phys_reservation = {
|
fake_phys_reservation = {
|
||||||
'resource_type': plugin.RESOURCE_TYPE,
|
'resource_type': plugin.RESOURCE_TYPE,
|
||||||
|
@ -2388,6 +2381,74 @@ class PhysicalHostPluginTestCase(tests.TestCase):
|
||||||
self.fake_phys_plugin._check_params(values)
|
self.fake_phys_plugin._check_params(values)
|
||||||
self.assertEqual(values['before_end'], 'default')
|
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):
|
class PhysicalHostMonitorPluginTestCase(tests.TestCase):
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"resource_properties": [
|
||||||
|
{
|
||||||
|
"property": "gpu",
|
||||||
|
"private": false,
|
||||||
|
"values": [
|
||||||
|
"True",
|
||||||
|
"False"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"resource_properties": [
|
||||||
|
{
|
||||||
|
"property": "gpu"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"private": true
|
||||||
|
}
|
|
@ -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
|
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
|
3. Use the leased resources
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
|
|
|
@ -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.
|
Loading…
Reference in New Issue