Merge "Add "owner" information field"

This commit is contained in:
Zuul 2018-12-11 18:56:47 +00:00 committed by Gerrit Code Review
commit 87b25d5447
21 changed files with 331 additions and 59 deletions

View File

@ -92,6 +92,9 @@ supplied when the Node is created, or the resource may be updated later.
.. versionadded:: 1.46 .. versionadded:: 1.46
Introduced the ``conductor_group`` field. Introduced the ``conductor_group`` field.
.. versionadded: 1.50
Introduced the ``owner`` field.
Normal response codes: 201 Normal response codes: 201
Error codes: 400,403,406 Error codes: 400,403,406
@ -120,6 +123,7 @@ Request
- storage_interface: req_storage_interface - storage_interface: req_storage_interface
- uuid: req_uuid - uuid: req_uuid
- vendor_interface: req_vendor_interface - vendor_interface: req_vendor_interface
- owner: owner
**Example Node creation request with a dynamic driver:** **Example Node creation request with a dynamic driver:**
@ -188,6 +192,7 @@ microversion 1.48.
- conductor_group: conductor_group - conductor_group: conductor_group
- protected: protected - protected: protected
- protected_reason: protected_reason - protected_reason: protected_reason
- owner: owner
**Example JSON representation of a Node:** **Example JSON representation of a Node:**
@ -235,6 +240,9 @@ provision state, and maintenance setting for each Node.
Introduced the ``conductor_group`` request parameter, to allow filtering the Introduced the ``conductor_group`` request parameter, to allow filtering the
list of returned nodes by conductor group. list of returned nodes by conductor group.
.. versionadded: 1.50
Introduced the ``owner`` field.
Normal response codes: 200 Normal response codes: 200
Error codes: 400,403,406 Error codes: 400,403,406
@ -258,6 +266,7 @@ Request
- sort_dir: sort_dir - sort_dir: sort_dir
- sort_key: sort_key - sort_key: sort_key
- detail: detail - detail: detail
- owner: owner
Response Response
-------- --------
@ -307,6 +316,9 @@ Nova instance, eg. with a request to ``v1/nodes/detail?instance_uuid={NOVA INSTA
.. versionadded:: 1.48 .. versionadded:: 1.48
Introduced the ``protected`` and ``protected_reason`` fields. Introduced the ``protected`` and ``protected_reason`` fields.
.. versionadded: 1.50
Introduced the ``owner`` field.
Normal response codes: 200 Normal response codes: 200
Error codes: 400,403,406 Error codes: 400,403,406
@ -324,6 +336,7 @@ Request
- driver: r_driver - driver: r_driver
- resource_class: r_resource_class - resource_class: r_resource_class
- conductor_group: r_conductor_group - conductor_group: r_conductor_group
- owner: owner
- limit: limit - limit: limit
- marker: marker - marker: marker
- sort_dir: sort_dir - sort_dir: sort_dir

View File

@ -856,6 +856,12 @@ nodes:
in: body in: body
required: true required: true
type: array type: array
owner:
description: |
A string or UUID of the tenant who owns the baremetal node.
in: body
required: false
type: string
passthru_async: passthru_async:
description: | description: |
If True the passthru function is invoked asynchronously; if False, If True the passthru function is invoked asynchronously; if False,

View File

@ -36,6 +36,7 @@
"management_interface": null, "management_interface": null,
"name": "test_node_classic", "name": "test_node_classic",
"network_interface": "flat", "network_interface": "flat",
"owner": null,
"portgroups": [ "portgroups": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups",

View File

@ -38,6 +38,7 @@
"management_interface": null, "management_interface": null,
"name": "test_node_classic", "name": "test_node_classic",
"network_interface": "flat", "network_interface": "flat",
"owner": null,
"portgroups": [ "portgroups": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups",

View File

@ -40,6 +40,7 @@
"management_interface": null, "management_interface": null,
"name": "test_node_classic", "name": "test_node_classic",
"network_interface": "flat", "network_interface": "flat",
"owner": null,
"portgroups": [ "portgroups": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups",

View File

@ -40,6 +40,7 @@
"management_interface": null, "management_interface": null,
"name": "test_node_classic", "name": "test_node_classic",
"network_interface": "flat", "network_interface": "flat",
"owner": "john doe",
"portgroups": [ "portgroups": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/portgroups",
@ -139,6 +140,7 @@
"management_interface": "ipmitool", "management_interface": "ipmitool",
"name": "test_node_dynamic", "name": "test_node_dynamic",
"network_interface": "flat", "network_interface": "flat",
"owner": "43e61ec9-8e42-4dcb-bc45-30d66aa93e5b",
"portgroups": [ "portgroups": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/2b045129-a906-46af-bc1a-092b294b3428/portgroups", "href": "http://127.0.0.1:6385/v1/nodes/2b045129-a906-46af-bc1a-092b294b3428/portgroups",

View File

@ -2,6 +2,14 @@
REST API Version History REST API Version History
======================== ========================
1.50 (Stein, master)
--------------------
Added ``owner`` field to the node object to enable operators to store
information in relation to the owner of a node. The field is up to 255
characters and MAY be used in a later point in time to allow designation
and deligation of permissions.
1.49 (Stein, master) 1.49 (Stein, master)
-------------------- --------------------

View File

@ -1075,6 +1075,9 @@ class Node(base.APIBase):
conductor = wsme.wsattr(wtypes.text, readonly=True) conductor = wsme.wsattr(wtypes.text, readonly=True)
"""Represent the conductor currently serving the node""" """Represent the conductor currently serving the node"""
owner = wsme.wsattr(wtypes.text)
"""Field for storage of physical node owner"""
# NOTE(deva): "conductor_affinity" shouldn't be presented on the # NOTE(deva): "conductor_affinity" shouldn't be presented on the
# API because it's an internal value. Don't add it here. # API because it's an internal value. Don't add it here.
@ -1277,7 +1280,7 @@ class Node(base.APIBase):
storage_interface=None, traits=[], rescue_interface=None, storage_interface=None, traits=[], rescue_interface=None,
bios_interface=None, conductor_group="", bios_interface=None, conductor_group="",
automated_clean=None, protected=False, automated_clean=None, protected=False,
protected_reason=None) protected_reason=None, owner=None)
# NOTE(matty_dubs): The chassis_uuid getter() is based on the # NOTE(matty_dubs): The chassis_uuid getter() is based on the
# _chassis_uuid variable: # _chassis_uuid variable:
sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12'
@ -1589,35 +1592,12 @@ class NodesController(rest.RestController):
filtered_nodes.append(n) filtered_nodes.append(n)
return filtered_nodes return filtered_nodes
def _create_node_filters(self, chassis_uuid=None, associated=None,
maintenance=None, provision_state=None,
driver=None, resource_class=None, fault=None,
conductor_group=None):
filters = {}
if chassis_uuid:
filters['chassis_uuid'] = chassis_uuid
if associated is not None:
filters['associated'] = associated
if maintenance is not None:
filters['maintenance'] = maintenance
if provision_state:
filters['provision_state'] = provision_state
if driver:
filters['driver'] = driver
if resource_class is not None:
filters['resource_class'] = resource_class
if fault is not None:
filters['fault'] = fault
if conductor_group is not None:
filters['conductor_group'] = conductor_group
return filters
def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated, def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated,
maintenance, provision_state, marker, limit, maintenance, provision_state, marker, limit,
sort_key, sort_dir, driver=None, sort_key, sort_dir, driver=None,
resource_class=None, resource_url=None, resource_class=None, resource_url=None,
fields=None, fault=None, conductor_group=None, fields=None, fault=None, conductor_group=None,
detail=None, conductor=None): detail=None, conductor=None, owner=None):
if self.from_chassis and not chassis_uuid: if self.from_chassis and not chassis_uuid:
raise exception.MissingParameterValue( raise exception.MissingParameterValue(
_("Chassis id not specified.")) _("Chassis id not specified."))
@ -1650,10 +1630,22 @@ class NodesController(rest.RestController):
# be generated, which we don't want. # be generated, which we don't want.
limit = 0 limit = 0
else: else:
filters = self._create_node_filters(chassis_uuid, associated, possible_filters = {
maintenance, provision_state, 'maintenance': maintenance,
driver, resource_class, fault, 'chassis_uuid': chassis_uuid,
conductor_group) 'associated': associated,
'provision_state': provision_state,
'driver': driver,
'resource_class': resource_class,
'fault': fault,
'conductor_group': conductor_group,
'owner': owner,
}
filters = {}
for key, value in possible_filters.items():
if value is not None:
filters[key] = value
nodes = objects.Node.list(pecan.request.context, limit, marker_obj, nodes = objects.Node.list(pecan.request.context, limit, marker_obj,
sort_key=sort_key, sort_dir=sort_dir, sort_key=sort_key, sort_dir=sort_dir,
filters=filters) filters=filters)
@ -1764,12 +1756,14 @@ class NodesController(rest.RestController):
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text, types.boolean, wtypes.text, types.uuid, int, wtypes.text,
wtypes.text, wtypes.text, types.listtype, wtypes.text, wtypes.text, wtypes.text, types.listtype, wtypes.text,
wtypes.text, wtypes.text, types.boolean, wtypes.text) wtypes.text, wtypes.text, types.boolean, wtypes.text,
wtypes.text)
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None, def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None, maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None, limit=None, sort_key='id', sort_dir='asc', driver=None,
fields=None, resource_class=None, fault=None, fields=None, resource_class=None, fault=None,
conductor_group=None, detail=None, conductor=None): conductor_group=None, detail=None, conductor=None,
owner=None):
"""Retrieve a list of nodes. """Retrieve a list of nodes.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for :param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@ -1799,6 +1793,8 @@ class NodesController(rest.RestController):
that conductor_group. that conductor_group.
:param conductor: Optional string value to get only nodes managed by :param conductor: Optional string value to get only nodes managed by
that conductor. that conductor.
:param owner: Optional string value that set the owner whose nodes
are to be retrurned.
:param fields: Optional, a list with a specified set of fields :param fields: Optional, a list with a specified set of fields
of the resource to be returned. of the resource to be returned.
:param fault: Optional string value to get only nodes with that fault. :param fault: Optional string value to get only nodes with that fault.
@ -1815,6 +1811,7 @@ class NodesController(rest.RestController):
api_utils.check_allow_filter_by_fault(fault) api_utils.check_allow_filter_by_fault(fault)
api_utils.check_allow_filter_by_conductor_group(conductor_group) api_utils.check_allow_filter_by_conductor_group(conductor_group)
api_utils.check_allow_filter_by_conductor(conductor) api_utils.check_allow_filter_by_conductor(conductor)
api_utils.check_allow_filter_by_owner(owner)
fields = api_utils.get_request_return_fields(fields, detail, fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS) _DEFAULT_RETURN_FIELDS)
@ -1828,18 +1825,19 @@ class NodesController(rest.RestController):
fields=fields, fault=fault, fields=fields, fault=fault,
conductor_group=conductor_group, conductor_group=conductor_group,
detail=detail, detail=detail,
conductor=conductor) conductor=conductor,
owner=owner)
@METRICS.timer('NodesController.detail') @METRICS.timer('NodesController.detail')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text, types.boolean, wtypes.text, types.uuid, int, wtypes.text,
wtypes.text, wtypes.text, wtypes.text, wtypes.text, wtypes.text, wtypes.text, wtypes.text, wtypes.text,
wtypes.text, wtypes.text) wtypes.text, wtypes.text, wtypes.text)
def detail(self, chassis_uuid=None, instance_uuid=None, associated=None, def detail(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None, maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None, limit=None, sort_key='id', sort_dir='asc', driver=None,
resource_class=None, fault=None, conductor_group=None, resource_class=None, fault=None, conductor_group=None,
conductor=None): conductor=None, owner=None):
"""Retrieve a list of nodes with detail. """Retrieve a list of nodes with detail.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for :param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@ -1868,6 +1866,8 @@ class NodesController(rest.RestController):
:param fault: Optional string value to get only nodes with that fault. :param fault: Optional string value to get only nodes with that fault.
:param conductor_group: Optional string value to get only nodes with :param conductor_group: Optional string value to get only nodes with
that conductor_group. that conductor_group.
:param owner: Optional string value that set the owner whose nodes
are to be retrurned.
""" """
cdict = pecan.request.context.to_policy_values() cdict = pecan.request.context.to_policy_values()
policy.authorize('baremetal:node:get', cdict, cdict) policy.authorize('baremetal:node:get', cdict, cdict)
@ -1877,6 +1877,7 @@ class NodesController(rest.RestController):
api_utils.check_allow_specify_resource_class(resource_class) api_utils.check_allow_specify_resource_class(resource_class)
api_utils.check_allow_filter_by_fault(fault) api_utils.check_allow_filter_by_fault(fault)
api_utils.check_allow_filter_by_conductor_group(conductor_group) api_utils.check_allow_filter_by_conductor_group(conductor_group)
api_utils.check_allow_filter_by_owner(owner)
api_utils.check_allowed_fields([sort_key]) api_utils.check_allowed_fields([sort_key])
# /detail should only work against collections # /detail should only work against collections
parent = pecan.request.path.split('/')[:-1][-1] parent = pecan.request.path.split('/')[:-1][-1]
@ -1895,7 +1896,8 @@ class NodesController(rest.RestController):
resource_url=resource_url, resource_url=resource_url,
fault=fault, fault=fault,
conductor_group=conductor_group, conductor_group=conductor_group,
conductor=conductor) conductor=conductor,
owner=owner)
@METRICS.timer('NodesController.validate') @METRICS.timer('NodesController.validate')
@expose.expose(wtypes.text, types.uuid_or_name, types.uuid) @expose.expose(wtypes.text, types.uuid_or_name, types.uuid)

View File

@ -379,6 +379,7 @@ VERSIONED_FIELDS = {
'protected': versions.MINOR_48_NODE_PROTECTED, 'protected': versions.MINOR_48_NODE_PROTECTED,
'protected_reason': versions.MINOR_48_NODE_PROTECTED, 'protected_reason': versions.MINOR_48_NODE_PROTECTED,
'conductor': versions.MINOR_49_CONDUCTORS, 'conductor': versions.MINOR_49_CONDUCTORS,
'owner': versions.MINOR_50_NODE_OWNER,
} }
for field in V31_FIELDS: for field in V31_FIELDS:
@ -544,6 +545,20 @@ def check_allow_filter_by_conductor_group(conductor_group):
'opr': versions.MINOR_46_NODE_CONDUCTOR_GROUP}) 'opr': versions.MINOR_46_NODE_CONDUCTOR_GROUP})
def check_allow_filter_by_owner(owner):
"""Check if filtering nodes by owner is allowed.
Version 1.50 of the API allows filtering nodes by owner.
"""
if (owner is not None and pecan.request.version.minor
< versions.MINOR_50_NODE_OWNER):
raise exception.NotAcceptable(_(
"Request not acceptable. The minimal required API version "
"should be %(base)s.%(opr)s") %
{'base': versions.BASE_VERSION,
'opr': versions.MINOR_50_NODE_OWNER})
def initial_node_provision_state(): def initial_node_provision_state():
"""Return node state to use by default when creating new nodes. """Return node state to use by default when creating new nodes.
@ -930,7 +945,7 @@ def get_request_return_fields(fields, detail, default_fields):
def allow_expose_conductors(): def allow_expose_conductors():
"""Check if accessing conductor endpoints is allowed. """Check if accessing conductor endpoints is allowed.
Version 1.48 of the API exposed conductor endpoints and conductor field Version 1.49 of the API exposed conductor endpoints and conductor field
for the node. for the node.
""" """
return pecan.request.version.minor >= versions.MINOR_49_CONDUCTORS return pecan.request.version.minor >= versions.MINOR_49_CONDUCTORS
@ -939,7 +954,7 @@ def allow_expose_conductors():
def check_allow_filter_by_conductor(conductor): def check_allow_filter_by_conductor(conductor):
"""Check if filtering nodes by conductor is allowed. """Check if filtering nodes by conductor is allowed.
Version 1.48 of the API allows filtering nodes by conductor. Version 1.49 of the API allows filtering nodes by conductor.
""" """
if conductor is not None and not allow_expose_conductors(): if conductor is not None and not allow_expose_conductors():
raise exception.NotAcceptable(_( raise exception.NotAcceptable(_(

View File

@ -86,6 +86,8 @@ BASE_VERSION = 1
# v1.46: Add conductor_group to the node object. # v1.46: Add conductor_group to the node object.
# v1.47: Add automated_clean to the node object. # v1.47: Add automated_clean to the node object.
# v1.48: Add protected to the node object. # v1.48: Add protected to the node object.
# v1.49: Exposes current conductor on the node object.
# v1.50: Add owner to the node object.
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -137,6 +139,7 @@ MINOR_46_NODE_CONDUCTOR_GROUP = 46
MINOR_47_NODE_AUTOMATED_CLEAN = 47 MINOR_47_NODE_AUTOMATED_CLEAN = 47
MINOR_48_NODE_PROTECTED = 48 MINOR_48_NODE_PROTECTED = 48
MINOR_49_CONDUCTORS = 49 MINOR_49_CONDUCTORS = 49
MINOR_50_NODE_OWNER = 50
# When adding another version, update: # When adding another version, update:
# - MINOR_MAX_VERSION # - MINOR_MAX_VERSION
@ -144,7 +147,7 @@ MINOR_49_CONDUCTORS = 49
# explanation of what changed in the new version # explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api'] # - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_49_CONDUCTORS MINOR_MAX_VERSION = MINOR_50_NODE_OWNER
# String representations of the minor and maximum versions # String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -131,10 +131,10 @@ RELEASE_MAPPING = {
} }
}, },
'master': { 'master': {
'api': '1.49', 'api': '1.50',
'rpc': '1.47', 'rpc': '1.47',
'objects': { 'objects': {
'Node': ['1.29', '1.28'], 'Node': ['1.30', '1.29', '1.28'],
'Conductor': ['1.3'], 'Conductor': ['1.3'],
'Chassis': ['1.3'], 'Chassis': ['1.3'],
'Port': ['1.8'], 'Port': ['1.8'],

View File

@ -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_owner
Revision ID: f190f9d00a11
Revises: 93706939026c
Create Date: 2018-11-12 00:33:58.575100
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f190f9d00a11'
down_revision = '93706939026c'
def upgrade():
op.add_column('nodes', sa.Column('owner', sa.String(255),
nullable=True))

View File

@ -224,7 +224,7 @@ class Connection(api.Connection):
'chassis_uuid', 'associated', 'reserved', 'chassis_uuid', 'associated', 'reserved',
'reserved_by_any_of', 'provisioned_before', 'reserved_by_any_of', 'provisioned_before',
'inspection_started_before', 'fault', 'inspection_started_before', 'fault',
'conductor_group'} 'conductor_group', 'owner'}
unsupported_filters = set(filters).difference(supported_filters) unsupported_filters = set(filters).difference(supported_filters)
if unsupported_filters: if unsupported_filters:
msg = _("SqlAlchemy API does not support " msg = _("SqlAlchemy API does not support "
@ -232,7 +232,7 @@ class Connection(api.Connection):
raise ValueError(msg) raise ValueError(msg)
for field in ['console_enabled', 'maintenance', 'driver', for field in ['console_enabled', 'maintenance', 'driver',
'resource_class', 'provision_state', 'uuid', 'id', 'resource_class', 'provision_state', 'uuid', 'id',
'fault', 'conductor_group']: 'fault', 'conductor_group', 'owner']:
if field in filters: if field in filters:
query = query.filter_by(**{field: filters[field]}) query = query.filter_by(**{field: filters[field]})
if 'chassis_uuid' in filters: if 'chassis_uuid' in filters:

View File

@ -179,7 +179,7 @@ class Node(Base):
protected = Column(Boolean, nullable=False, default=False, protected = Column(Boolean, nullable=False, default=False,
server_default=false()) server_default=false())
protected_reason = Column(Text, nullable=True) protected_reason = Column(Text, nullable=True)
owner = Column(String(255), nullable=True)
bios_interface = Column(String(255), nullable=True) bios_interface = Column(String(255), nullable=True)
boot_interface = Column(String(255), nullable=True) boot_interface = Column(String(255), nullable=True)
console_interface = Column(String(255), nullable=True) console_interface = Column(String(255), nullable=True)

View File

@ -66,7 +66,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# Version 1.27: Add conductor_group field # Version 1.27: Add conductor_group field
# Version 1.28: Add automated_clean field # Version 1.28: Add automated_clean field
# Version 1.29: Add protected and protected_reason fields # Version 1.29: Add protected and protected_reason fields
VERSION = '1.29' # Version 1.30: Add owner field
VERSION = '1.30'
dbapi = db_api.get_instance() dbapi = db_api.get_instance()
@ -149,6 +150,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
'storage_interface': object_fields.StringField(nullable=True), 'storage_interface': object_fields.StringField(nullable=True),
'vendor_interface': object_fields.StringField(nullable=True), 'vendor_interface': object_fields.StringField(nullable=True),
'traits': object_fields.ObjectField('TraitList', nullable=True), 'traits': object_fields.ObjectField('TraitList', nullable=True),
'owner': object_fields.StringField(nullable=True),
} }
def as_dict(self, secure=False): def as_dict(self, secure=False):
@ -581,6 +583,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
should be set to None (or removed). should be set to None (or removed).
Version 1.29: protected was added. For versions prior to this, it Version 1.29: protected was added. For versions prior to this, it
should be set to False (or removed). should be set to False (or removed).
Version 1.30: owner 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 target_version: the desired version of the object
:param remove_unavailable_fields: True to remove fields that are :param remove_unavailable_fields: True to remove fields that are
@ -593,7 +597,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# Convert the different fields depending on version # Convert the different fields depending on version
fields = [('rescue_interface', 22), ('traits', 23), fields = [('rescue_interface', 22), ('traits', 23),
('bios_interface', 24), ('automated_clean', 28), ('bios_interface', 24), ('automated_clean', 28),
('protected_reason', 29)] ('protected_reason', 29), ('owner', 30)]
for name, minor in fields: for name, minor in fields:
self._adjust_field_to_version(name, None, target_version, self._adjust_field_to_version(name, None, target_version,
1, minor, remove_unavailable_fields) 1, minor, remove_unavailable_fields)
@ -648,6 +652,7 @@ class NodePayload(notification.NotificationPayloadBase):
'rescue_interface': ('node', 'rescue_interface'), 'rescue_interface': ('node', 'rescue_interface'),
'storage_interface': ('node', 'storage_interface'), 'storage_interface': ('node', 'storage_interface'),
'vendor_interface': ('node', 'vendor_interface'), 'vendor_interface': ('node', 'vendor_interface'),
'owner': ('node', 'owner'),
'power_state': ('node', 'power_state'), 'power_state': ('node', 'power_state'),
'properties': ('node', 'properties'), 'properties': ('node', 'properties'),
'protected': ('node', 'protected'), 'protected': ('node', 'protected'),
@ -674,7 +679,8 @@ class NodePayload(notification.NotificationPayloadBase):
# Version 1.9: Add deploy_step field exposed via API. # Version 1.9: Add deploy_step field exposed via API.
# Version 1.10: Add conductor_group field exposed via API. # Version 1.10: Add conductor_group field exposed via API.
# Version 1.11: Add protected and protected_reason fields exposed via API. # Version 1.11: Add protected and protected_reason fields exposed via API.
VERSION = '1.11' # Version 1.12: Add node owner field.
VERSION = '1.12'
fields = { fields = {
'clean_step': object_fields.FlexibleDictField(nullable=True), 'clean_step': object_fields.FlexibleDictField(nullable=True),
'conductor_group': object_fields.StringField(nullable=True), 'conductor_group': object_fields.StringField(nullable=True),
@ -703,6 +709,7 @@ class NodePayload(notification.NotificationPayloadBase):
'storage_interface': object_fields.StringField(nullable=True), 'storage_interface': object_fields.StringField(nullable=True),
'vendor_interface': object_fields.StringField(nullable=True), 'vendor_interface': object_fields.StringField(nullable=True),
'name': object_fields.StringField(nullable=True), 'name': object_fields.StringField(nullable=True),
'owner': object_fields.StringField(nullable=True),
'power_state': object_fields.StringField(nullable=True), 'power_state': object_fields.StringField(nullable=True),
'properties': object_fields.FlexibleDictField(nullable=True), 'properties': object_fields.FlexibleDictField(nullable=True),
'protected': object_fields.BooleanField(nullable=True), 'protected': object_fields.BooleanField(nullable=True),
@ -754,7 +761,8 @@ class NodeSetPowerStatePayload(NodePayload):
# Version 1.9: Parent NodePayload version 1.9 # Version 1.9: Parent NodePayload version 1.9
# Version 1.10: Parent NodePayload version 1.10 # Version 1.10: Parent NodePayload version 1.10
# Version 1.11: Parent NodePayload version 1.11 # Version 1.11: Parent NodePayload version 1.11
VERSION = '1.11' # Version 1.12: Parent NodePayload version 1.12
VERSION = '1.12'
fields = { fields = {
# "to_power" indicates the future target_power_state of the node. A # "to_power" indicates the future target_power_state of the node. A
@ -806,7 +814,8 @@ class NodeCorrectedPowerStatePayload(NodePayload):
# Version 1.9: Parent NodePayload version 1.9 # Version 1.9: Parent NodePayload version 1.9
# Version 1.10: Parent NodePayload version 1.10 # Version 1.10: Parent NodePayload version 1.10
# Version 1.11: Parent NodePayload version 1.11 # Version 1.11: Parent NodePayload version 1.11
VERSION = '1.11' # Version 1.12: Parent NodePayload version 1.12
VERSION = '1.12'
fields = { fields = {
'from_power': object_fields.StringField(nullable=True) 'from_power': object_fields.StringField(nullable=True)
@ -842,7 +851,8 @@ class NodeSetProvisionStatePayload(NodePayload):
# Version 1.9: Parent NodePayload version 1.9 # Version 1.9: Parent NodePayload version 1.9
# Version 1.10: Parent NodePayload version 1.10 # Version 1.10: Parent NodePayload version 1.10
# Version 1.11: Parent NodePayload version 1.11 # Version 1.11: Parent NodePayload version 1.11
VERSION = '1.11' # Version 1.12: Parent NodePayload version 1.12
VERSION = '1.12'
SCHEMA = dict(NodePayload.SCHEMA, SCHEMA = dict(NodePayload.SCHEMA,
**{'instance_info': ('node', 'instance_info')}) **{'instance_info': ('node', 'instance_info')})
@ -885,7 +895,8 @@ class NodeCRUDPayload(NodePayload):
# Version 1.7: Parent NodePayload version 1.9 # Version 1.7: Parent NodePayload version 1.9
# Version 1.8: Parent NodePayload version 1.10 # Version 1.8: Parent NodePayload version 1.10
# Version 1.9: Parent NodePayload version 1.11 # Version 1.9: Parent NodePayload version 1.11
VERSION = '1.9' # Version 1.10: Parent NodePayload version 1.12
VERSION = '1.10'
SCHEMA = dict(NodePayload.SCHEMA, SCHEMA = dict(NodePayload.SCHEMA,
**{'instance_info': ('node', 'instance_info'), **{'instance_info': ('node', 'instance_info'),

View File

@ -132,6 +132,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('automated_clean', data['nodes'][0]) self.assertNotIn('automated_clean', data['nodes'][0])
self.assertNotIn('protected', data['nodes'][0]) self.assertNotIn('protected', data['nodes'][0])
self.assertNotIn('protected_reason', data['nodes'][0]) self.assertNotIn('protected_reason', data['nodes'][0])
self.assertNotIn('owner', data['nodes'][0])
def test_get_one(self): def test_get_one(self):
node = obj_utils.create_test_node(self.context, node = obj_utils.create_test_node(self.context,
@ -173,6 +174,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('automated_clean', data) self.assertIn('automated_clean', data)
self.assertIn('protected', data) self.assertIn('protected', data)
self.assertIn('protected_reason', data) self.assertIn('protected_reason', data)
self.assertIn('owner', data)
def test_get_one_with_json(self): def test_get_one_with_json(self):
# Test backward compatibility with guess_content_type_from_ext # Test backward compatibility with guess_content_type_from_ext
@ -326,6 +328,23 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertTrue(data['protected']) self.assertTrue(data['protected'])
self.assertEqual('reason!', data['protected_reason']) self.assertEqual('reason!', data['protected_reason'])
def test_node_owner_hidden_in_lower_version(self):
self._test_node_field_hidden_in_lower_version('owner',
'1.49', '1.50')
def test_node_owner_null_field(self):
node = obj_utils.create_test_node(self.context, owner=None)
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: '1.50'})
self.assertIsNone(data['owner'])
def test_node_owner_present(self):
node = obj_utils.create_test_node(self.context,
owner="akindofmagic")
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: '1.50'})
self.assertEqual(data['owner'], "akindofmagic")
def test_get_one_custom_fields(self): def test_get_one_custom_fields(self):
node = obj_utils.create_test_node(self.context, node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id) chassis_id=self.chassis.id)
@ -517,6 +536,13 @@ class TestListNodes(test_api_base.BaseApiTest):
headers={api_base.Version.string: '1.49'}) headers={api_base.Version.string: '1.49'})
self.assertIn('conductor', response) self.assertIn('conductor', response)
def test_get_owner_fields(self):
node = obj_utils.create_test_node(self.context, owner='fred')
fields = 'owner'
response = self.get_json('/nodes/%s?fields=%s' % (node.uuid, fields),
headers={api_base.Version.string: '1.50'})
self.assertIn('owner', response)
def test_detail(self): def test_detail(self):
node = obj_utils.create_test_node(self.context, node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id) chassis_id=self.chassis.id)
@ -550,6 +576,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('automated_clean', data['nodes'][0]) self.assertIn('automated_clean', data['nodes'][0])
self.assertIn('protected', data['nodes'][0]) self.assertIn('protected', data['nodes'][0])
self.assertIn('protected_reason', data['nodes'][0]) self.assertIn('protected_reason', data['nodes'][0])
self.assertIn('owner', data['nodes'][0])
# never expose the chassis_id # never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0]) self.assertNotIn('chassis_id', data['nodes'][0])
@ -582,6 +609,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('automated_clean', data['nodes'][0]) self.assertIn('automated_clean', data['nodes'][0])
self.assertIn('protected', data['nodes'][0]) self.assertIn('protected', data['nodes'][0])
self.assertIn('protected_reason', data['nodes'][0]) self.assertIn('protected_reason', data['nodes'][0])
self.assertIn('owner', data['nodes'][0])
for field in api_utils.V31_FIELDS: for field in api_utils.V31_FIELDS:
self.assertIn(field, data['nodes'][0]) self.assertIn(field, data['nodes'][0])
# never expose the chassis_id # never expose the chassis_id
@ -1575,7 +1603,7 @@ class TestListNodes(test_api_base.BaseApiTest):
def test_get_nodes_by_conductor_not_allowed(self): def test_get_nodes_by_conductor_not_allowed(self):
response = self.get_json('/nodes?conductor=rocky.rocks', response = self.get_json('/nodes?conductor=rocky.rocks',
headers={api_base.Version.string: "1.47"}, headers={api_base.Version.string: "1.48"},
expect_errors=True) expect_errors=True)
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
@ -1608,6 +1636,36 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn(node1.uuid, uuids) self.assertNotIn(node1.uuid, uuids)
self.assertIn(node2.uuid, uuids) self.assertIn(node2.uuid, uuids)
def test_get_nodes_by_owner(self):
node1 = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
owner='fred')
node2 = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
owner='bob')
for base_url in ('/nodes', '/nodes/detail'):
data = self.get_json(base_url + '?owner=fred',
headers={api_base.Version.string: "1.50"})
uuids = [n['uuid'] for n in data['nodes']]
self.assertIn(node1.uuid, uuids)
self.assertNotIn(node2.uuid, uuids)
data = self.get_json(base_url + '?owner=bob',
headers={api_base.Version.string: "1.50"})
uuids = [n['uuid'] for n in data['nodes']]
self.assertIn(node2.uuid, uuids)
self.assertNotIn(node1.uuid, uuids)
def test_get_nodes_by_owner_not_allowed(self):
for url in ('/nodes?owner=fred',
'/nodes/detail?owner=fred'):
response = self.get_json(
url, headers={api_base.Version.string: "1.48"},
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): def test_get_console_information(self):
node = obj_utils.create_test_node(self.context) node = obj_utils.create_test_node(self.context)
expected_console_info = {'test': 'test-data'} expected_console_info = {'test': 'test-data'}
@ -2783,6 +2841,19 @@ class TestPatch(test_api_base.BaseApiTest):
response = self.patch_json('/nodes/%s' % node.uuid, response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/protected_reason', [{'path': '/protected_reason',
'value': 'reason!', 'value': 'reason!',
'op': 'replace'}],
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
def test_update_owner(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.50'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/owner',
'value': 'meow',
'op': 'replace'}], 'op': 'replace'}],
headers=headers) headers=headers)
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
@ -2815,6 +2886,20 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertEqual(http_client.BAD_REQUEST, response.status_code) self.assertEqual(http_client.BAD_REQUEST, response.status_code)
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_update_owner_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.47'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/owner',
'value': 'meow',
'op': 'replace'}],
headers=headers,
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
def _create_node_locally(node): def _create_node_locally(node):
driver_factory.check_and_update_node_interfaces(node) driver_factory.check_and_update_node_interfaces(node)
@ -3422,6 +3507,25 @@ class TestPost(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_int) self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_create_node_owner(self):
ndict = test_api_utils.post_get_test_node(owner='cowsay')
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('cowsay', result['owner'])
def test_create_node_owner_old_api_version(self):
headers = {api_base.Version.string: '1.32'}
ndict = test_api_utils.post_get_test_node(owner='bob')
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): class TestDelete(test_api_base.BaseApiTest):

View File

@ -786,6 +786,11 @@ class MigrationCheckersMixin(object):
self.assertFalse(node['protected']) self.assertFalse(node['protected'])
self.assertIsNone(node['protected_reason']) self.assertIsNone(node['protected_reason'])
def _check_f190f9d00a11(self, engine, data):
nodes = db_utils.get_table(engine, 'nodes')
col_names = [column.name for column in nodes.c]
self.assertIn('owner', col_names)
def test_upgrade_and_version(self): def test_upgrade_and_version(self):
with patch_with_engine(self.engine): with patch_with_engine(self.engine):
self.migration_api.upgrade('head') self.migration_api.upgrade('head')

View File

@ -218,6 +218,7 @@ def get_test_node(**kw):
'protected': kw.get('protected', False), 'protected': kw.get('protected', False),
'protected_reason': kw.get('protected_reason', None), 'protected_reason': kw.get('protected_reason', None),
'conductor': kw.get('conductor'), 'conductor': kw.get('conductor'),
'owner': kw.get('owner', None),
} }
for iface in drivers_base.ALL_INTERFACES: for iface in drivers_base.ALL_INTERFACES:

View File

@ -775,9 +775,7 @@ class TestConvertToVersion(db_base.DbTestCase):
delattr(node, 'protected') delattr(node, 'protected')
delattr(node, 'protected_reason') delattr(node, 'protected_reason')
node.obj_reset_changes() node.obj_reset_changes()
node._convert_to_version("1.29") node._convert_to_version("1.29")
self.assertFalse(node.protected) self.assertFalse(node.protected)
self.assertIsNone(node.protected_reason) self.assertIsNone(node.protected_reason)
self.assertEqual({'protected': False, 'protected_reason': None}, self.assertEqual({'protected': False, 'protected_reason': None},
@ -829,6 +827,67 @@ class TestConvertToVersion(db_base.DbTestCase):
self.assertEqual({'protected': False, 'protected_reason': None}, self.assertEqual({'protected': False, 'protected_reason': None},
node.obj_get_changes()) node.obj_get_changes())
def test_owner_supported_missing(self):
# owner_interface not set, should be set to default.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
delattr(node, 'owner')
node.obj_reset_changes()
node._convert_to_version("1.30")
self.assertIsNone(node.owner)
self.assertEqual({'owner': None},
node.obj_get_changes())
def test_owner_supported_set(self):
# owner set, no change required.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.owner = "Sure, there is an owner"
node.obj_reset_changes()
node._convert_to_version("1.30")
self.assertEqual("Sure, there is an owner", node.owner)
self.assertEqual({}, node.obj_get_changes())
def test_owner_unsupported_missing(self):
# owner not set, no change required.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
delattr(node, 'owner')
node.obj_reset_changes()
node._convert_to_version("1.29")
self.assertNotIn('owner', node)
self.assertEqual({}, node.obj_get_changes())
def test_owner_unsupported_set_remove(self):
# owner set, should be removed.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.owner = "magic"
node.obj_reset_changes()
node._convert_to_version("1.29")
self.assertNotIn('owner', node)
self.assertEqual({}, node.obj_get_changes())
def test_owner_unsupported_set_no_remove_non_default(self):
# owner set, should be set to default.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.owner = "magic"
node.obj_reset_changes()
node._convert_to_version("1.29", False)
self.assertIsNone(node.owner)
self.assertEqual({'owner': None},
node.obj_get_changes())
def test_owner_unsupported_set_no_remove_default(self):
# owner set, no change required.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.owner = None
node.obj_reset_changes()
node._convert_to_version("1.29", False)
self.assertIsNone(node.owner)
self.assertEqual({}, node.obj_get_changes())
class TestNodePayloads(db_base.DbTestCase): class TestNodePayloads(db_base.DbTestCase):
@ -886,6 +945,7 @@ class TestNodePayloads(db_base.DbTestCase):
self.assertEqual(self.node.traits.get_trait_names(), payload.traits) self.assertEqual(self.node.traits.get_trait_names(), payload.traits)
self.assertEqual(self.node.updated_at, payload.updated_at) self.assertEqual(self.node.updated_at, payload.updated_at)
self.assertEqual(self.node.uuid, payload.uuid) self.assertEqual(self.node.uuid, payload.uuid)
self.assertEqual(self.node.owner, payload.owner)
def test_node_payload(self): def test_node_payload(self):
payload = objects.NodePayload(self.node) payload = objects.NodePayload(self.node)

View File

@ -677,7 +677,7 @@ class TestObject(_LocalTest, _TestObject):
# version bump. It is an MD5 hash of the object fields and remotable methods. # 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. # The fingerprint values should only be changed if there is a version bump.
expected_object_fingerprints = { expected_object_fingerprints = {
'Node': '1.29-7af860bb4017751104558139c52a1327', 'Node': '1.30-8313460d6ea5457a527cd3d85e5ee3d8',
'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6',
'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905',
'Port': '1.8-898a47921f4a1f53fcdddd4eeb179e0b', 'Port': '1.8-898a47921f4a1f53fcdddd4eeb179e0b',
@ -685,21 +685,21 @@ expected_object_fingerprints = {
'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a', 'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a',
'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370',
'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d',
'NodePayload': '1.11-f323602c2e9c3edbf2a5567eca087ff5', 'NodePayload': '1.12-7d650c2a024357275990681f020512e4',
'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15',
'NodeSetPowerStatePayload': '1.11-b61e66ef9d100a2cc564d16b12810855', 'NodeSetPowerStatePayload': '1.12-703d110d571cc95b2947bb6bd153fcb8',
'NodeCorrectedPowerStateNotification': 'NodeCorrectedPowerStateNotification':
'1.0-59acc533c11d306f149846f922739c15', '1.0-59acc533c11d306f149846f922739c15',
'NodeCorrectedPowerStatePayload': '1.11-e6e32a38ca655509802ac3c6d8bc17f6', 'NodeCorrectedPowerStatePayload': '1.12-29cbb6b20a0aeea9e0ab9e17302e9e16',
'NodeSetProvisionStateNotification': 'NodeSetProvisionStateNotification':
'1.0-59acc533c11d306f149846f922739c15', '1.0-59acc533c11d306f149846f922739c15',
'NodeSetProvisionStatePayload': '1.11-d13cb3472eea163de5b0723a08e95d2c', 'NodeSetProvisionStatePayload': '1.12-a302ce357ad39a0a4d1ca3c0ee44f0e0',
'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97', 'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97',
'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e', 'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e',
'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202', 'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202',
'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'NodeCRUDPayload': '1.9-c5e57432274371f7fe32f269519033cf', 'NodeCRUDPayload': '1.10-49590dee863c5ed1193f5deae0a0a2f2',
'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'PortCRUDPayload': '1.2-233d259df442eb15cc584fae1fe81504', 'PortCRUDPayload': '1.2-233d259df442eb15cc584fae1fe81504',
'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15',

View File

@ -0,0 +1,7 @@
---
features:
- |
Adds API version 1.50 which allows for the storage of an ``owner`` field
on node objects. This is intended for either storage of human parsable
information or the storage of a tenant UUID which could be leveraged
in a future version of the Bare Metal as a Service API.