From 602a467a04a0cae3a2d65ba45bfd70eabd4c105a Mon Sep 17 00:00:00 2001 From: Tzu-Mainn Chen Date: Tue, 7 Jan 2020 22:25:08 +0000 Subject: [PATCH] Add node lessee field This change adds a `lessee` field to nodes, and exposes it to policy. It also updates the non-admin node list API to match for both owner and lessee; and updates the allocation conductor to match owner allocations with nodes with the appropriate lessee. Change-Id: Ib31b49c7143ec8fd6cb486fc24038215b197c418 Story: 2006506 Task: 37930 --- api-ref/source/baremetal-api-v1-nodes.inc | 19 +++ api-ref/source/parameters.yaml | 6 + .../source/samples/node-create-response.json | 1 + .../source/samples/node-show-response.json | 1 + .../node-update-driver-info-response.json | 1 + .../samples/nodes-list-details-response.json | 2 + .../contributor/webapi-version-history.rst | 8 + ironic/api/controllers/v1/node.py | 42 +++-- ironic/api/controllers/v1/utils.py | 27 +++- ironic/api/controllers/v1/versions.py | 4 +- ironic/common/policy.py | 3 + ironic/common/release_mappings.py | 4 +- ironic/conductor/allocations.py | 2 +- .../versions/b2ad35726bb0_add_node_lessee.py | 32 ++++ ironic/db/sqlalchemy/api.py | 8 +- ironic/db/sqlalchemy/models.py | 1 + ironic/objects/node.py | 25 ++- .../unit/api/controllers/v1/test_node.py | 145 ++++++++++++++++-- .../unit/api/controllers/v1/test_utils.py | 25 ++- ironic/tests/unit/common/test_policy.py | 13 ++ .../tests/unit/conductor/test_allocations.py | 10 +- .../unit/db/sqlalchemy/test_migrations.py | 5 + ironic/tests/unit/db/test_nodes.py | 29 ++++ ironic/tests/unit/db/utils.py | 2 +- ironic/tests/unit/objects/test_node.py | 62 ++++++++ ironic/tests/unit/objects/test_objects.py | 12 +- .../notes/node-lessee-4fb320a597192742.yaml | 5 + 27 files changed, 440 insertions(+), 54 deletions(-) create mode 100644 ironic/db/sqlalchemy/alembic/versions/b2ad35726bb0_add_node_lessee.py create mode 100644 releasenotes/notes/node-lessee-4fb320a597192742.yaml diff --git a/api-ref/source/baremetal-api-v1-nodes.inc b/api-ref/source/baremetal-api-v1-nodes.inc index 78e8a0f1c0..49d5cf436b 100644 --- a/api-ref/source/baremetal-api-v1-nodes.inc +++ b/api-ref/source/baremetal-api-v1-nodes.inc @@ -101,6 +101,9 @@ supplied when the Node is created, or the resource may be updated later. .. versionadded:: 1.52 Introduced the ``allocation_uuid`` field. +.. versionadded:: 1.65 + Introduced the ``lessee`` field. + Normal response codes: 201 Error codes: 400,403,406 @@ -131,6 +134,7 @@ Request - vendor_interface: req_vendor_interface - owner: owner - description: n_description + - lessee: lessee **Example Node creation request with a dynamic driver:** @@ -201,6 +205,7 @@ microversion 1.48. - protected_reason: protected_reason - conductor: conductor - owner: owner + - lessee: lessee - description: n_description - allocation_uuid: allocation_uuid @@ -260,6 +265,9 @@ provision state, and maintenance setting for each Node. .. versionadded:: 1.51 Introduced the ``description`` field. +.. versionadded:: 1.65 + Introduced the ``lessee`` field. + Normal response codes: 200 Error codes: 400,403,406 @@ -279,6 +287,7 @@ Request - conductor: r_conductor - fault: r_fault - owner: owner + - lessee: lessee - description_contains: r_description_contains - fields: fields - limit: limit @@ -347,6 +356,9 @@ Nova instance, eg. with a request to ``v1/nodes/detail?instance_uuid={NOVA INSTA .. versionadded:: 1.52 Introduced the ``allocation_uuid`` field. +.. versionadded:: 1.65 + Introduced the ``lessee`` field. + Normal response codes: 200 Error codes: 400,403,406 @@ -366,6 +378,7 @@ Request - conductor_group: r_conductor_group - conductor: r_conductor - owner: owner + - lessee: lessee - description_contains: r_description_contains - limit: limit - marker: marker @@ -423,6 +436,7 @@ Response - protected: protected - protected_reason: protected_reason - owner: owner + - lessee: lessee - description: n_description - conductor: conductor - allocation_uuid: allocation_uuid @@ -474,6 +488,9 @@ only the specified set. .. versionadded:: 1.61 Introduced the ``retired`` and ``retired_reason`` fields. +.. versionadded:: 1.65 + Introduced the ``lessee`` field. + Normal response codes: 200 Error codes: 400,403,404,406 @@ -537,6 +554,7 @@ Response - protected: protected - protected_reason: protected_reason - owner: owner + - lessee: lessee - description: n_description - conductor: conductor - allocation_uuid: allocation_uuid @@ -632,6 +650,7 @@ Response - protected: protected - protected_reason: protected_reason - owner: owner + - lessee: lessee - description: n_description - conductor: conductor - allocation_uuid: allocation_uuid diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 4967fc61d5..59567b4881 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -910,6 +910,12 @@ last_error: in: body required: true type: string +lessee: + description: | + A string or UUID of the tenant who is leasing the object. + in: body + required: false + type: string links: description: | A list of relative links. Includes the self and diff --git a/api-ref/source/samples/node-create-response.json b/api-ref/source/samples/node-create-response.json index 84932a2356..214305d0e0 100644 --- a/api-ref/source/samples/node-create-response.json +++ b/api-ref/source/samples/node-create-response.json @@ -23,6 +23,7 @@ "instance_info": {}, "instance_uuid": null, "last_error": null, + "lessee": null, "links": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d", diff --git a/api-ref/source/samples/node-show-response.json b/api-ref/source/samples/node-show-response.json index 3a520c96bf..9cb1931b02 100644 --- a/api-ref/source/samples/node-show-response.json +++ b/api-ref/source/samples/node-show-response.json @@ -26,6 +26,7 @@ "instance_info": {}, "instance_uuid": null, "last_error": null, + "lessee": null, "links": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d", diff --git a/api-ref/source/samples/node-update-driver-info-response.json b/api-ref/source/samples/node-update-driver-info-response.json index 05665a3dc7..f7d2d88ee1 100644 --- a/api-ref/source/samples/node-update-driver-info-response.json +++ b/api-ref/source/samples/node-update-driver-info-response.json @@ -27,6 +27,7 @@ "instance_info": {}, "instance_uuid": null, "last_error": null, + "lessee": null, "links": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d", diff --git a/api-ref/source/samples/nodes-list-details-response.json b/api-ref/source/samples/nodes-list-details-response.json index 870a625586..90dc72a2a0 100644 --- a/api-ref/source/samples/nodes-list-details-response.json +++ b/api-ref/source/samples/nodes-list-details-response.json @@ -28,6 +28,7 @@ "instance_info": {}, "instance_uuid": "5344a3e2-978a-444e-990a-cbf47c62ef88", "last_error": null, + "lessee": null, "links": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d", @@ -132,6 +133,7 @@ "instance_info": {}, "instance_uuid": null, "last_error": null, + "lessee": null, "links": [ { "href": "http://127.0.0.1:6385/v1/nodes/2b045129-a906-46af-bc1a-092b294b3428", diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 774fe4c248..e7feb584f8 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,14 @@ REST API Version History ======================== +1.65 (Ussuri, master) +--------------------- + +Added ``lessee`` field to the node object. The field should match the +``project_id`` of the intended lessee. If an allocation has an owner, +then the allocation process will only match the allocation with a node +that has the same ``owner`` or ``lessee``. + 1.64 (Ussuri, master) --------------------- diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 7e26d5c828..2faf738a58 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -1250,7 +1250,10 @@ class Node(base.APIBase): owner = wsme.wsattr(str) """Field for storage of physical node owner""" - description = wsme.wsattr(str) + lessee = wsme.wsattr(wtypes.text) + """Field for storage of physical node lessee""" + + description = wsme.wsattr(wtypes.text) """Field for node description""" allocation_uuid = wsme.wsattr(types.uuid, readonly=True) @@ -1482,7 +1485,7 @@ class Node(base.APIBase): automated_clean=None, protected=False, protected_reason=None, owner=None, allocation_uuid='982ddb5b-bce5-4d23-8fb8-7f710f648cd5', - retired=False, retired_reason=None) + retired=False, retired_reason=None, lessee=None) # NOTE(matty_dubs): The chassis_uuid getter() is based on the # _chassis_uuid variable: sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' @@ -1801,6 +1804,7 @@ class NodesController(rest.RestController): resource_class=None, resource_url=None, fields=None, fault=None, conductor_group=None, detail=None, conductor=None, owner=None, + lessee=None, project=None, description_contains=None): if self.from_chassis and not chassis_uuid: raise exception.MissingParameterValue( @@ -1844,6 +1848,8 @@ class NodesController(rest.RestController): 'fault': fault, 'conductor_group': conductor_group, 'owner': owner, + 'lessee': lessee, + 'project': project, 'description_contains': description_contains, 'retired': retired, } @@ -1970,13 +1976,14 @@ class NodesController(rest.RestController): types.boolean, types.boolean, str, types.uuid, int, str, str, str, types.listtype, str, str, str, types.boolean, str, - str, str) + str, str, str, str) def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None, maintenance=None, retired=None, provision_state=None, marker=None, limit=None, sort_key='id', sort_dir='asc', driver=None, fields=None, resource_class=None, fault=None, conductor_group=None, detail=None, conductor=None, - owner=None, description_contains=None): + owner=None, description_contains=None, lessee=None, + project=None): """Retrieve a list of nodes. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -2010,6 +2017,10 @@ class NodesController(rest.RestController): that conductor. :param owner: Optional string value that set the owner whose nodes are to be retrurned. + :param lessee: Optional string value that set the lessee whose nodes + are to be returned. + :param project: Optional string value that set the project - lessee or + owner - whose nodes are to be returned. :param fields: Optional, a list with a specified set of fields of the resource to be returned. :param fault: Optional string value to get only nodes with that fault. @@ -2017,7 +2028,7 @@ class NodesController(rest.RestController): with description field contains matching value. """ - owner = api_utils.check_list_policy('node', owner) + project = api_utils.check_list_policy('node', project) api_utils.check_allow_specify_fields(fields) api_utils.check_allowed_fields(fields) @@ -2029,6 +2040,7 @@ class NodesController(rest.RestController): api_utils.check_allow_filter_by_conductor_group(conductor_group) api_utils.check_allow_filter_by_conductor(conductor) api_utils.check_allow_filter_by_owner(owner) + api_utils.check_allow_filter_by_lessee(lessee) fields = api_utils.get_request_return_fields(fields, detail, _DEFAULT_RETURN_FIELDS) @@ -2044,20 +2056,22 @@ class NodesController(rest.RestController): conductor_group=conductor_group, detail=detail, conductor=conductor, - owner=owner, + owner=owner, lessee=lessee, + project=project, **extra_args) @METRICS.timer('NodesController.detail') @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, types.boolean, types.boolean, str, types.uuid, int, str, str, str, str, str, - str, str, str, str) + str, str, str, str, + str, str) def detail(self, chassis_uuid=None, instance_uuid=None, associated=None, maintenance=None, retired=None, provision_state=None, marker=None, limit=None, sort_key='id', sort_dir='asc', driver=None, resource_class=None, fault=None, conductor_group=None, conductor=None, owner=None, - description_contains=None): + description_contains=None, lessee=None, project=None): """Retrieve a list of nodes with detail. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -2090,11 +2104,15 @@ class NodesController(rest.RestController): that conductor_group. :param owner: Optional string value that set the owner whose nodes are to be retrurned. + :param lessee: Optional string value that set the lessee whose nodes + are to be returned. + :param project: Optional string value that set the project - lessee or + owner - whose nodes are to be returned. :param description_contains: Optional string value to get only nodes with description field contains matching value. """ - owner = api_utils.check_list_policy('node', owner) + project = api_utils.check_list_policy('node', project) api_utils.check_for_invalid_state_and_allow_filter(provision_state) api_utils.check_allow_specify_driver(driver) @@ -2102,6 +2120,7 @@ class NodesController(rest.RestController): api_utils.check_allow_filter_by_fault(fault) api_utils.check_allow_filter_by_conductor_group(conductor_group) api_utils.check_allow_filter_by_owner(owner) + api_utils.check_allow_filter_by_lessee(lessee) api_utils.check_allowed_fields([sort_key]) # /detail should only work against collections parent = api.request.path.split('/')[:-1][-1] @@ -2122,7 +2141,8 @@ class NodesController(rest.RestController): fault=fault, conductor_group=conductor_group, conductor=conductor, - owner=owner, + owner=owner, lessee=lessee, + project=project, **extra_args) @METRICS.timer('NodesController.validate') @@ -2341,7 +2361,7 @@ class NodesController(rest.RestController): api_utils.check_owner_policy( 'node', 'baremetal:node:update_owner_provisioned', - rpc_node['owner']) + rpc_node['owner'], rpc_node['lessee']) except exception.HTTPForbidden: msg = _('Cannot update owner of node "%(node)s" while it ' 'is in state "%(state)s".') % { diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index b840845c49..ebadb71073 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -490,6 +490,7 @@ VERSIONED_FIELDS = { 'events': versions.MINOR_54_EVENTS, 'retired': versions.MINOR_61_NODE_RETIRED, 'retired_reason': versions.MINOR_61_NODE_RETIRED, + 'lessee': versions.MINOR_65_NODE_LESSEE, } for field in V31_FIELDS: @@ -717,6 +718,20 @@ def check_allow_filter_by_owner(owner): 'opr': versions.MINOR_50_NODE_OWNER}) +def check_allow_filter_by_lessee(lessee): + """Check if filtering nodes by lessee is allowed. + + Version 1.62 of the API allows filtering nodes by lessee. + """ + if (lessee is not None and api.request.version.minor + < versions.MINOR_65_NODE_LESSEE): + raise exception.NotAcceptable(_( + "Request not acceptable. The minimal required API version " + "should be %(base)s.%(opr)s") % + {'base': versions.BASE_VERSION, + 'opr': versions.MINOR_65_NODE_LESSEE}) + + def initial_node_provision_state(): """Return node state to use by default when creating new nodes. @@ -1165,12 +1180,13 @@ def check_policy(policy_name): policy.authorize(policy_name, cdict, cdict) -def check_owner_policy(object_type, policy_name, owner): +def check_owner_policy(object_type, policy_name, owner, lessee=None): """Check if the policy authorizes this request on an object. :param: object_type: type of object being checked :param: policy_name: Name of the policy to check. :param: owner: the owner + :param: lessee: the lessee :raises: HTTPForbidden if the policy forbids access. """ @@ -1178,6 +1194,8 @@ def check_owner_policy(object_type, policy_name, owner): target_dict = dict(cdict) target_dict[object_type + '.owner'] = owner + if lessee: + target_dict[object_type + '.lessee'] = lessee policy.authorize(policy_name, target_dict, cdict) @@ -1205,7 +1223,8 @@ def check_node_policy_and_retrieve(policy_name, node_ident, policy.authorize(policy_name, cdict, cdict) raise - check_owner_policy('node', policy_name, rpc_node['owner']) + check_owner_policy('node', policy_name, + rpc_node['owner'], rpc_node['lessee']) return rpc_node @@ -1253,7 +1272,8 @@ def check_multiple_node_policies_and_retrieve(policy_names, node_ident, with_suffix) else: - check_owner_policy('node', policy_name, rpc_node['owner']) + check_owner_policy('node', policy_name, + rpc_node['owner'], rpc_node['lessee']) return rpc_node @@ -1303,6 +1323,7 @@ def check_port_policy_and_retrieve(policy_name, port_uuid): rpc_node = objects.Node.get_by_id(context, rpc_port.node_id) target_dict = dict(cdict) target_dict['node.owner'] = rpc_node['owner'] + target_dict['node.lessee'] = rpc_node['lessee'] policy.authorize(policy_name, target_dict, cdict) return rpc_port, rpc_node diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 69915f0e2e..26b5c77227 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -102,6 +102,7 @@ BASE_VERSION = 1 # v1.62: Add agent_token support for agent communication. # v1.63: Add support for indicators # v1.64: Add network_type to port.local_link_connection +# v1.65: Add lessee to the node object. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -168,6 +169,7 @@ MINOR_61_NODE_RETIRED = 61 MINOR_62_AGENT_TOKEN = 62 MINOR_63_INDICATORS = 63 MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE = 64 +MINOR_65_NODE_LESSEE = 65 # When adding another version, update: # - MINOR_MAX_VERSION @@ -175,7 +177,7 @@ MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE = 64 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE +MINOR_MAX_VERSION = MINOR_65_NODE_LESSEE # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/policy.py b/ironic/common/policy.py index 9d0c1f7a33..8b7495875d 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -66,6 +66,9 @@ default_policies = [ policy.RuleDefault('is_node_owner', 'project_id:%(node.owner)s', description='Owner of node'), + policy.RuleDefault('is_node_lessee', + 'project_id:%(node.lessee)s', + description='Lessee of node'), policy.RuleDefault('is_allocation_owner', 'project_id:%(allocation.owner)s', description='Owner of allocation'), diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index d7fe545f93..c67c8cbfa0 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -214,11 +214,11 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.64', + 'api': '1.65', 'rpc': '1.50', 'objects': { 'Allocation': ['1.1'], - 'Node': ['1.33', '1.32'], + 'Node': ['1.34', '1.33', '1.32'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], 'DeployTemplate': ['1.1'], diff --git a/ironic/conductor/allocations.py b/ironic/conductor/allocations.py index d1492bf38f..d8fc38a5e4 100644 --- a/ironic/conductor/allocations.py +++ b/ironic/conductor/allocations.py @@ -113,7 +113,7 @@ def _candidate_nodes(context, allocation): # UUIDs on the API level. filters['uuid_in'] = allocation.candidate_nodes if allocation.owner: - filters['owner'] = allocation.owner + filters['project'] = allocation.owner nodes = objects.Node.list(context, filters=filters) diff --git a/ironic/db/sqlalchemy/alembic/versions/b2ad35726bb0_add_node_lessee.py b/ironic/db/sqlalchemy/alembic/versions/b2ad35726bb0_add_node_lessee.py new file mode 100644 index 0000000000..5e36bf7157 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/b2ad35726bb0_add_node_lessee.py @@ -0,0 +1,32 @@ +# All Rights Reserved. +# +# 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. +"""add node lessee + +Revision ID: b2ad35726bb0 +Revises: ce6c4b3cf5a2 +Create Date: 2020-01-07 20:49:50.851441 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'b2ad35726bb0' +down_revision = 'cd2c80feb331' + + +def upgrade(): + op.add_column('nodes', sa.Column('lessee', sa.String(255), + nullable=True)) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 95baf75fde..e3f62335f2 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -288,7 +288,7 @@ class Connection(api.Connection): _NODE_QUERY_FIELDS = {'console_enabled', 'maintenance', 'retired', 'driver', 'resource_class', 'provision_state', 'uuid', 'id', 'fault', 'conductor_group', - 'owner'} + 'owner', 'lessee'} _NODE_IN_QUERY_FIELDS = {'%s_in' % field: field for field in ('uuid', 'provision_state')} _NODE_NON_NULL_FILTERS = {'associated': 'instance_uuid', @@ -296,7 +296,7 @@ class Connection(api.Connection): 'with_power_state': 'power_state'} _NODE_FILTERS = ({'chassis_uuid', 'reserved_by_any_of', 'provisioned_before', 'inspection_started_before', - 'description_contains'} + 'description_contains', 'project'} | _NODE_QUERY_FIELDS | set(_NODE_IN_QUERY_FIELDS) | set(_NODE_NON_NULL_FILTERS)) @@ -354,6 +354,10 @@ class Connection(api.Connection): if keyword is not None: query = query.filter( models.Node.description.like(r'%{}%'.format(keyword))) + if 'project' in filters: + project = filters['project'] + query = query.filter((models.Node.owner == project) + | (models.Node.lessee == project)) return query diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index 863183bf53..e1df97aceb 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -180,6 +180,7 @@ class Node(Base): server_default=false()) protected_reason = Column(Text, nullable=True) owner = Column(String(255), nullable=True) + lessee = Column(String(255), nullable=True) allocation_id = Column(Integer, ForeignKey('allocations.id'), nullable=True) description = Column(Text, nullable=True) diff --git a/ironic/objects/node.py b/ironic/objects/node.py index 0daf237797..cdb02ebb98 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -74,7 +74,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.31: Add allocation_id field # Version 1.32: Add description field # Version 1.33: Add retired and retired_reason fields - VERSION = '1.33' + # Version 1.34: Add lessee field + VERSION = '1.34' dbapi = db_api.get_instance() @@ -159,6 +160,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'vendor_interface': object_fields.StringField(nullable=True), 'traits': object_fields.ObjectField('TraitList', nullable=True), 'owner': object_fields.StringField(nullable=True), + 'lessee': object_fields.StringField(nullable=True), 'description': object_fields.StringField(nullable=True), 'retired': objects.fields.BooleanField(nullable=True), 'retired_reason': object_fields.StringField(nullable=True), @@ -602,6 +604,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): should be set to None (or removed). Version 1.33: retired was added. For versions prior to this, it should be set to False (or removed). + Version 1.34: lessee was added. For versions prior to this, it should + be set to None or removed. :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are @@ -616,7 +620,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): ('bios_interface', 24), ('fault', 25), ('automated_clean', 28), ('protected_reason', 29), ('owner', 30), ('allocation_id', 31), ('description', 32), - ('retired_reason', 33)] + ('retired_reason', 33), ('lessee', 34)] for name, minor in fields: self._adjust_field_to_version(name, None, target_version, 1, minor, remove_unavailable_fields) @@ -675,6 +679,7 @@ class NodePayload(notification.NotificationPayloadBase): 'storage_interface': ('node', 'storage_interface'), 'vendor_interface': ('node', 'vendor_interface'), 'owner': ('node', 'owner'), + 'lessee': ('node', 'lessee'), 'power_state': ('node', 'power_state'), 'properties': ('node', 'properties'), 'protected': ('node', 'protected'), @@ -706,7 +711,8 @@ class NodePayload(notification.NotificationPayloadBase): # Version 1.12: Add node owner field. # Version 1.13: Add description field. # Version 1.14: Add retired and retired_reason fields exposed via API. - VERSION = '1.14' + # Version 1.15: Add node lessee field. + VERSION = '1.15' fields = { 'clean_step': object_fields.FlexibleDictField(nullable=True), 'conductor_group': object_fields.StringField(nullable=True), @@ -737,6 +743,7 @@ class NodePayload(notification.NotificationPayloadBase): 'vendor_interface': object_fields.StringField(nullable=True), 'name': object_fields.StringField(nullable=True), 'owner': object_fields.StringField(nullable=True), + 'lessee': object_fields.StringField(nullable=True), 'power_state': object_fields.StringField(nullable=True), 'properties': object_fields.FlexibleDictField(nullable=True), 'protected': object_fields.BooleanField(nullable=True), @@ -793,7 +800,8 @@ class NodeSetPowerStatePayload(NodePayload): # Version 1.12: Parent NodePayload version 1.12 # Version 1.13: Parent NodePayload version 1.13 # Version 1.14: Parent NodePayload version 1.14 - VERSION = '1.14' + # Version 1.15: Parent NodePayload version 1.15 + VERSION = '1.15' fields = { # "to_power" indicates the future target_power_state of the node. A @@ -848,7 +856,8 @@ class NodeCorrectedPowerStatePayload(NodePayload): # Version 1.12: Parent NodePayload version 1.12 # Version 1.13: Parent NodePayload version 1.13 # Version 1.14: Parent NodePayload version 1.14 - VERSION = '1.14' + # Version 1.15: Parent NodePayload version 1.15 + VERSION = '1.15' fields = { 'from_power': object_fields.StringField(nullable=True) @@ -887,7 +896,8 @@ class NodeSetProvisionStatePayload(NodePayload): # Version 1.12: Parent NodePayload version 1.12 # Version 1.13: Parent NodePayload version 1.13 # Version 1.14: Parent NodePayload version 1.14 - VERSION = '1.14' + # Version 1.15: Parent NodePayload version 1.15 + VERSION = '1.15' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info')}) @@ -933,7 +943,8 @@ class NodeCRUDPayload(NodePayload): # Version 1.10: Parent NodePayload version 1.12 # Version 1.11: Parent NodePayload version 1.13 # Version 1.12: Parent NodePayload version 1.14 - VERSION = '1.12' + # Version 1.13: Parent NodePayload version 1.15 + VERSION = '1.13' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info'), diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index da7f2fe22f..323de9e079 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -137,6 +137,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('owner', data['nodes'][0]) self.assertNotIn('retired', data['nodes'][0]) self.assertNotIn('retired_reason', data['nodes'][0]) + self.assertNotIn('lessee', data['nodes'][0]) def test_get_one(self): node = obj_utils.create_test_node(self.context, @@ -179,6 +180,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('protected', data) self.assertIn('protected_reason', data) self.assertIn('owner', data) + self.assertIn('lessee', data) self.assertNotIn('allocation_id', data) self.assertIn('allocation_uuid', data) @@ -384,6 +386,23 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertTrue(data['retired']) self.assertEqual('warranty expired', data['retired_reason']) + def test_node_lessee_hidden_in_lower_version(self): + self._test_node_field_hidden_in_lower_version('lessee', + '1.64', '1.65') + + def test_node_lessee_null_field(self): + node = obj_utils.create_test_node(self.context, lessee=None) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.65'}) + self.assertIsNone(data['lessee']) + + def test_node_lessee_present(self): + node = obj_utils.create_test_node(self.context, + lessee="some-lucky-project") + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.65'}) + self.assertEqual(data['lessee'], "some-lucky-project") + def test_get_one_custom_fields(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -590,6 +609,14 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.51'}) self.assertIn('description', response) + def test_get_lessee_field(self): + node = obj_utils.create_test_node(self.context, + lessee='some-lucky-project') + fields = 'lessee' + response = self.get_json('/nodes/%s?fields=%s' % (node.uuid, fields), + headers={api_base.Version.string: '1.65'}) + self.assertIn('lessee', response) + def test_get_with_allocation(self): allocation = obj_utils.create_test_allocation(self.context) node = obj_utils.create_test_node(self.context, @@ -650,6 +677,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('protected', data['nodes'][0]) self.assertIn('protected_reason', data['nodes'][0]) self.assertIn('owner', data['nodes'][0]) + self.assertIn('lessee', data['nodes'][0]) # never expose the chassis_id self.assertNotIn('chassis_id', data['nodes'][0]) self.assertNotIn('allocation_id', data['nodes'][0]) @@ -687,6 +715,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('protected', data['nodes'][0]) self.assertIn('protected_reason', data['nodes'][0]) self.assertIn('owner', data['nodes'][0]) + self.assertIn('lessee', data['nodes'][0]) for field in api_utils.V31_FIELDS: self.assertIn(field, data['nodes'][0]) # never expose the chassis_id @@ -764,14 +793,14 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertEqual(http_client.FORBIDDEN, response.status_int) @mock.patch.object(policy, 'authorize', spec=True) - def test_detail_list_all_forbid_owner_proj_mismatch(self, mock_authorize): + def test_detail_list_all_forbid_project_mismatch(self, mock_authorize): def mock_authorize_function(rule, target, creds): if rule == 'baremetal:node:list_all': raise exception.HTTPForbidden(resource='fake') return True mock_authorize.side_effect = mock_authorize_function - response = self.get_json('/nodes/detail?owner=54321', + response = self.get_json('/nodes/detail?project=54321', expect_errors=True, headers={ api_base.Version.string: '1.50', @@ -788,17 +817,27 @@ class TestListNodes(test_api_base.BaseApiTest): mock_authorize.side_effect = mock_authorize_function nodes = [] - for id in range(5): + for id in range(3): node = obj_utils.create_test_node(self.context, uuid=uuidutils.generate_uuid(), owner='12345') nodes.append(node.uuid) + for id in range(3): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='12345') + nodes.append(node.uuid) for id in range(2): node = obj_utils.create_test_node(self.context, - uuid=uuidutils.generate_uuid()) + uuid=uuidutils.generate_uuid(), + owner='54321') + for id in range(2): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='54321') data = self.get_json('/nodes/detail', headers={ - api_base.Version.string: '1.50', + api_base.Version.string: '1.65', 'X-Project-Id': '12345'}) self.assertEqual(len(nodes), len(data['nodes'])) @@ -1005,14 +1044,14 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertEqual(http_client.FORBIDDEN, response.status_int) @mock.patch.object(policy, 'authorize', spec=True) - def test_many_list_all_forbid_owner_proj_mismatch(self, mock_authorize): + def test_many_list_all_forbid_project_mismatch(self, mock_authorize): def mock_authorize_function(rule, target, creds): if rule == 'baremetal:node:list_all': raise exception.HTTPForbidden(resource='fake') return True mock_authorize.side_effect = mock_authorize_function - response = self.get_json('/nodes?owner=54321', + response = self.get_json('/nodes?project=54321', expect_errors=True, headers={ api_base.Version.string: '1.50', @@ -1029,17 +1068,27 @@ class TestListNodes(test_api_base.BaseApiTest): mock_authorize.side_effect = mock_authorize_function nodes = [] - for id in range(5): + for id in range(3): node = obj_utils.create_test_node(self.context, uuid=uuidutils.generate_uuid(), owner='12345') nodes.append(node.uuid) + for id in range(3): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='12345') + nodes.append(node.uuid) for id in range(2): node = obj_utils.create_test_node(self.context, - uuid=uuidutils.generate_uuid()) + uuid=uuidutils.generate_uuid(), + owner='54321') + for id in range(2): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='54321') data = self.get_json('/nodes', headers={ - api_base.Version.string: '1.50', + api_base.Version.string: '1.65', 'X-Project-Id': '12345'}) self.assertEqual(len(nodes), len(data['nodes'])) @@ -1956,6 +2005,36 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn(node2.uuid, uuids) self.assertNotIn(node1.uuid, uuids) + def test_get_nodes_by_lessee(self): + node1 = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='project1') + node2 = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='project2') + + for base_url in ('/nodes', '/nodes/detail'): + data = self.get_json(base_url + '?lessee=project1', + headers={api_base.Version.string: "1.65"}) + uuids = [n['uuid'] for n in data['nodes']] + self.assertIn(node1.uuid, uuids) + self.assertNotIn(node2.uuid, uuids) + data = self.get_json(base_url + '?lessee=project2', + headers={api_base.Version.string: "1.65"}) + uuids = [n['uuid'] for n in data['nodes']] + self.assertIn(node2.uuid, uuids) + self.assertNotIn(node1.uuid, uuids) + + def test_get_nodes_by_lessee_not_allowed(self): + for url in ('/nodes?lessee=project1', + '/nodes/detail?lessee=project1'): + response = self.get_json( + url, headers={api_base.Version.string: "1.64"}, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + self.assertTrue(response.json['error_message']) + def test_get_console_information(self): node = obj_utils.create_test_node(self.context) expected_console_info = {'test': 'test-data'} @@ -3474,6 +3553,33 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.BAD_REQUEST, response.status_code) + def test_update_lessee(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.65'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/lessee', + 'value': 'new-project', + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_lessee_old_api(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.64'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/lessee', + 'value': 'new-project', + 'op': 'replace'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + def test_patch_allocation_forbidden(self): node = obj_utils.create_test_node(self.context, uuid=uuidutils.generate_uuid()) @@ -4359,6 +4465,25 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.BAD_REQUEST, response.status_int) + def test_create_node_lessee(self): + ndict = test_api_utils.post_get_test_node(lessee='project') + response = self.post_json('/nodes', ndict, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/nodes/%s' % ndict['uuid'], + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual('project', result['lessee']) + + def test_create_node_lessee_old_api_version(self): + headers = {api_base.Version.string: '1.64'} + ndict = test_api_utils.post_get_test_node(lessee='project') + response = self.post_json('/nodes', ndict, headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int) + class TestDelete(test_api_base.BaseApiTest): diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py index 077ddc3023..a9948a3493 100644 --- a/ironic/tests/unit/api/controllers/v1/test_utils.py +++ b/ironic/tests/unit/api/controllers/v1/test_utils.py @@ -803,6 +803,7 @@ class TestCheckOwnerPolicy(base.TestCase): self.valid_node_uuid = uuidutils.generate_uuid() self.node = test_api_utils.post_get_test_node() self.node['owner'] = '12345' + self.node['lessee'] = '54321' @mock.patch.object(api, 'request', spec_set=["context", "version"]) @mock.patch.object(policy, 'authorize', spec=True) @@ -813,10 +814,11 @@ class TestCheckOwnerPolicy(base.TestCase): mock_pr.context.to_policy_values.return_value = {} utils.check_owner_policy( - 'node', 'fake_policy', self.node['owner'] + 'node', 'fake_policy', self.node['owner'], self.node['lessee'] ) mock_authorize.assert_called_once_with( - 'fake_policy', {'node.owner': '12345'}, {}) + 'fake_policy', + {'node.owner': '12345', 'node.lessee': '54321'}, {}) @mock.patch.object(api, 'request', spec_set=["context", "version"]) @mock.patch.object(policy, 'authorize', spec=True) @@ -832,7 +834,7 @@ class TestCheckOwnerPolicy(base.TestCase): utils.check_owner_policy, 'node', 'fake-policy', - self.node['owner'] + self.node ) @@ -842,6 +844,7 @@ class TestCheckNodePolicyAndRetrieve(base.TestCase): self.valid_node_uuid = uuidutils.generate_uuid() self.node = test_api_utils.post_get_test_node() self.node['owner'] = '12345' + self.node['lessee'] = '54321' @mock.patch.object(api, 'request', spec_set=["context", "version"]) @mock.patch.object(policy, 'authorize', spec=True) @@ -860,7 +863,8 @@ class TestCheckNodePolicyAndRetrieve(base.TestCase): mock_grn.assert_called_once_with(self.valid_node_uuid) mock_grnws.assert_not_called() mock_authorize.assert_called_once_with( - 'fake_policy', {'node.owner': '12345'}, {}) + 'fake_policy', + {'node.owner': '12345', 'node.lessee': '54321'}, {}) self.assertEqual(self.node, rpc_node) @mock.patch.object(api, 'request', spec_set=["context", "version"]) @@ -880,7 +884,8 @@ class TestCheckNodePolicyAndRetrieve(base.TestCase): mock_grn.assert_not_called() mock_grnws.assert_called_once_with(self.valid_node_uuid) mock_authorize.assert_called_once_with( - 'fake_policy', {'node.owner': '12345'}, {}) + 'fake_policy', + {'node.owner': '12345', 'node.lessee': '54321'}, {}) self.assertEqual(self.node, rpc_node) @mock.patch.object(api, 'request', spec_set=["context"]) @@ -1022,6 +1027,7 @@ class TestCheckMultipleNodePoliciesAndRetrieve(base.TestCase): self.valid_node_uuid = uuidutils.generate_uuid() self.node = test_api_utils.post_get_test_node() self.node['owner'] = '12345' + self.node['lessee'] = '54321' @mock.patch.object(utils, 'check_node_policy_and_retrieve') @mock.patch.object(utils, 'check_owner_policy') @@ -1037,7 +1043,7 @@ class TestCheckMultipleNodePoliciesAndRetrieve(base.TestCase): mock_cnpar.assert_called_once_with('fake_policy_1', self.valid_node_uuid, False) mock_cop.assert_called_once_with( - 'node', 'fake_policy_2', '12345') + 'node', 'fake_policy_2', '12345', '54321') self.assertEqual(self.node, rpc_node) @mock.patch.object(utils, 'check_node_policy_and_retrieve') @@ -1075,7 +1081,7 @@ class TestCheckMultipleNodePoliciesAndRetrieve(base.TestCase): mock_cnpar.assert_called_once_with('fake_policy_1', self.valid_node_uuid, False) mock_cop.assert_called_once_with( - 'node', 'fake_policy_2', '12345') + 'node', 'fake_policy_2', '12345', '54321') class TestCheckListPolicy(base.TestCase): @@ -1190,6 +1196,7 @@ class TestCheckPortPolicyAndRetrieve(base.TestCase): self.valid_port_uuid = uuidutils.generate_uuid() self.node = test_api_utils.post_get_test_node() self.node['owner'] = '12345' + self.node['lessee'] = '54321' self.port = objects.Port(self.context, node_id=42) @mock.patch.object(api, 'request', spec_set=["context", "version"]) @@ -1211,7 +1218,9 @@ class TestCheckPortPolicyAndRetrieve(base.TestCase): self.valid_port_uuid) mock_ngbi.assert_called_once_with(mock_pr.context, 42) mock_authorize.assert_called_once_with( - 'fake_policy', {'node.owner': '12345'}, {}) + 'fake_policy', + {'node.owner': '12345', 'node.lessee': '54321'}, + {}) self.assertEqual(self.port, rpc_port) self.assertEqual(self.node, rpc_node) diff --git a/ironic/tests/unit/common/test_policy.py b/ironic/tests/unit/common/test_policy.py index 4277524b79..046706554c 100644 --- a/ironic/tests/unit/common/test_policy.py +++ b/ironic/tests/unit/common/test_policy.py @@ -69,6 +69,19 @@ class PolicyInCodeTestCase(base.TestCase): self.assertTrue(policy.check('is_node_owner', target, c1)) self.assertFalse(policy.check('is_node_owner', target, c2)) + def test_is_node_lessee(self): + c1 = {'project_id': '1234', + 'project_name': 'demo', + 'project_domain_id': 'default'} + c2 = {'project_id': '5678', + 'project_name': 'demo', + 'project_domain_id': 'default'} + target = dict.copy(c1) + target['node.lessee'] = '1234' + + self.assertTrue(policy.check('is_node_lessee', target, c1)) + self.assertFalse(policy.check('is_node_lessee', target, c2)) + def test_is_allocation_owner(self): c1 = {'project_id': '1234', 'project_name': 'demo', diff --git a/ironic/tests/unit/conductor/test_allocations.py b/ironic/tests/unit/conductor/test_allocations.py index 69108450ed..18b6025bf8 100644 --- a/ironic/tests/unit/conductor/test_allocations.py +++ b/ironic/tests/unit/conductor/test_allocations.py @@ -350,14 +350,20 @@ class DoAllocateTestCase(db_base.DbTestCase): @mock.patch.object(task_manager, 'acquire', autospec=True, side_effect=task_manager.acquire) - def test_nodes_filtered_out_owner(self, mock_acquire): - # Owner does not match + def test_nodes_filtered_out_project(self, mock_acquire): + # Owner and lessee do not match obj_utils.create_test_node(self.context, uuid=uuidutils.generate_uuid(), owner='54321', resource_class='x-large', power_state='power off', provision_state='available') + obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + lessee='54321', + resource_class='x-large', + power_state='power off', + provision_state='available') allocation = obj_utils.create_test_allocation(self.context, resource_class='x-large', diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index ae690fc464..7aed87626b 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -990,6 +990,11 @@ class MigrationCheckersMixin(object): self.assertFalse(node['retired']) self.assertIsNone(node['retired_reason']) + def _check_b2ad35726bb0(self, engine, data): + nodes = db_utils.get_table(engine, 'nodes') + col_names = [column.name for column in nodes.c] + self.assertIn('lessee', col_names) + def test_upgrade_and_version(self): with patch_with_engine(self.engine): self.migration_api.upgrade('head') diff --git a/ironic/tests/unit/db/test_nodes.py b/ironic/tests/unit/db/test_nodes.py index 07401a27dc..88200bf99f 100644 --- a/ironic/tests/unit/db/test_nodes.py +++ b/ironic/tests/unit/db/test_nodes.py @@ -399,6 +399,35 @@ class DbNodeTestCase(base.DbTestCase): self.dbapi.get_node_list, filters=filters) + def test_get_node_list_filter_by_project(self): + utils.create_test_node(uuid=uuidutils.generate_uuid()) + node2 = utils.create_test_node( + uuid=uuidutils.generate_uuid(), + owner='project1', + lessee='project2', + ) + node3 = utils.create_test_node( + uuid=uuidutils.generate_uuid(), + owner='project2', + ) + node4 = utils.create_test_node( + uuid=uuidutils.generate_uuid(), + owner='project1', + lessee='project3', + ) + + res = self.dbapi.get_node_list(filters={'project': 'project1'}) + self.assertEqual([node2.id, node4.id], [r.id for r in res]) + + res = self.dbapi.get_node_list(filters={'project': 'project2'}) + self.assertEqual([node2.id, node3.id], [r.id for r in res]) + + res = self.dbapi.get_node_list(filters={'project': 'project3'}) + self.assertEqual([node4.id], [r.id for r in res]) + + res = self.dbapi.get_node_list(filters={'project': 'flargle'}) + self.assertEqual([], [r.id for r in res]) + def test_get_node_list_description(self): node1 = utils.create_test_node(uuid=uuidutils.generate_uuid(), description='Hello') diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index e8a4c45e3a..e18f1b33a3 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -226,7 +226,7 @@ def get_test_node(**kw): 'description': kw.get('description'), 'retired': kw.get('retired', False), 'retired_reason': kw.get('retired_reason', None), - + 'lessee': kw.get('lessee', None), } for iface in drivers_base.ALL_INTERFACES: diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py index 18693047ea..b027cff108 100644 --- a/ironic/tests/unit/objects/test_node.py +++ b/ironic/tests/unit/objects/test_node.py @@ -1134,6 +1134,68 @@ class TestConvertToVersion(db_base.DbTestCase): self.assertIsNone(node.description) self.assertEqual({}, node.obj_get_changes()) + def test_lessee_supported_missing(self): + # lessee not set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + delattr(node, 'lessee') + node.obj_reset_changes() + node._convert_to_version("1.34") + self.assertIsNone(node.lessee) + self.assertEqual({'lessee': None}, + node.obj_get_changes()) + + def test_lessee_supported_set(self): + # lessee set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.lessee = "some-lucky-project" + node.obj_reset_changes() + node._convert_to_version("1.34") + self.assertEqual("some-lucky-project", + node.lessee) + self.assertEqual({}, node.obj_get_changes()) + + def test_lessee_unsupported_missing(self): + # lessee not set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + delattr(node, 'lessee') + node.obj_reset_changes() + node._convert_to_version("1.33") + self.assertNotIn('lessee', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_lessee_unsupported_set_remove(self): + # lessee set, should be removed. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.lessee = "some-lucky-project" + node.obj_reset_changes() + node._convert_to_version("1.33") + self.assertNotIn('lessee', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_lessee_unsupported_set_no_remove_non_default(self): + # lessee set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.lessee = "some-lucky-project" + node.obj_reset_changes() + node._convert_to_version("1.33", False) + self.assertIsNone(node.lessee) + self.assertEqual({'lessee': None}, + node.obj_get_changes()) + + def test_lessee_unsupported_set_no_remove_default(self): + # lessee set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.lessee = None + node.obj_reset_changes() + node._convert_to_version("1.33", False) + self.assertIsNone(node.lessee) + self.assertEqual({}, node.obj_get_changes()) + class TestNodePayloads(db_base.DbTestCase): diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 457c8be3cf..621b7f9398 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -676,7 +676,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is an MD5 hash of the object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.33-d6a8ba8dd3be3b2bbad0e0a5b9887aa8', + 'Node': '1.34-ae873e627cf30bf28fe9f98a807b6200', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.9-0cb9202a4ec442e8c0d87a324155eaaf', @@ -684,21 +684,21 @@ expected_object_fingerprints = { 'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', - 'NodePayload': '1.14-8b2dfc37d800f268d29a580ac034e2c6', + 'NodePayload': '1.15-86ee30dbf374be4cf17c5b501d9e2e7b', 'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetPowerStatePayload': '1.14-dcd4d7911717ba323ab4c3297b92c31c', + 'NodeSetPowerStatePayload': '1.15-3c64b07a2b96c2661e7743b47ed43705', 'NodeCorrectedPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeCorrectedPowerStatePayload': '1.14-c7d20e953bbb9a1a4ce31ce22068e4bf', + 'NodeCorrectedPowerStatePayload': '1.15-59a224a9191cdc9f1acc2e0dcd2d3adb', 'NodeSetProvisionStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetProvisionStatePayload': '1.14-6d4145044a98c5cc80a40d69bbd98f61', + 'NodeSetProvisionStatePayload': '1.15-488a3d62a0643d17e288ecf89ed5bbb4', 'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97', 'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e', 'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202', 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeCRUDPayload': '1.12-3f63cdace5159785535049025ddf6a5c', + 'NodeCRUDPayload': '1.13-8f673253ff8d7389897a6a80d224ac33', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortCRUDPayload': '1.3-21235916ed54a91b2a122f59571194e7', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', diff --git a/releasenotes/notes/node-lessee-4fb320a597192742.yaml b/releasenotes/notes/node-lessee-4fb320a597192742.yaml new file mode 100644 index 0000000000..aacc692634 --- /dev/null +++ b/releasenotes/notes/node-lessee-4fb320a597192742.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Adds a ``lessee`` field to nodes. This field is exposed to policy, so if + a policy file permits, a lessee will have access to specified node APIs.