Add Ironic Port resource type support

Change-Id: If6b1d77d055b9a3803e50563a4f41c2dcdcb1f31
Task: 36286
This commit is contained in:
ricolin 2019-08-15 12:51:41 +08:00
parent be68ab8583
commit 5dccfb9144
4 changed files with 519 additions and 0 deletions

View File

@ -0,0 +1,240 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from heat.common import exception
from heat.common.i18n import _
from heat.engine import attributes
from heat.engine import constraints
from heat.engine import properties
from heat.engine import resource
from heat.engine import support
from heat.engine import translation
class Port(resource.Resource):
"""A resource that creates a ironic port.
Node UUID and physical hardware address for the Port (MAC address in
most cases) are needed (all Ports must be associated to a Node when
created).
"""
support_status = support.SupportStatus(version='13.0.0')
default_client_name = 'ironic'
entity = 'port'
PROPERTIES = (
NODE, ADDRESS, PORTGROUP, LOCAL_LINK_CONNECTION, PXE_ENABLED,
PHYSICAL_NETWORK, EXTRA, IS_SMARTNIC,
) = (
'node', 'address', 'portgroup', 'local_link_connection', 'pxe_enabled',
'physical_network', 'extra', 'is_smartnic',
)
PROPERTIES_MIN_SUPPORT_VERSION = (
(PXE_ENABLED, 1.19),
(LOCAL_LINK_CONNECTION, 1.191),
(PORTGROUP, 1.24), (PHYSICAL_NETWORK, 1.34),
(IS_SMARTNIC, 1.53)
)
ATTRIBUTES = (
ADDRESS_ATTR, NODE_UUID_ATTR, PORTGROUP_UUID_ATTR,
LOCAL_LINK_CONNECTION_ATTR, PXE_ENABLED_ATTR, PHYSICAL_NETWORK_ATTR,
INTERNAL_INFO_ATTR, EXTRA_ATTR, IS_SMARTNIC_ATTR,
) = (
'address', 'node_uuid', 'portgroup_uuid',
'local_link_connection', 'pxe_enabled', 'physical_network',
'internal_info', 'extra', 'is_smartnic',
)
attributes_schema = {
ADDRESS_ATTR: attributes.Schema(
_('Physical hardware address of this network Port, typically the '
'hardware MAC address.'),
type=attributes.Schema.STRING
),
NODE_UUID_ATTR: attributes.Schema(
_('UUID of the Node this resource belongs to.'),
type=attributes.Schema.STRING
),
PORTGROUP_UUID_ATTR: attributes.Schema(
_('UUID of the Portgroup this resource belongs to.'),
type=attributes.Schema.STRING
),
LOCAL_LINK_CONNECTION_ATTR: attributes.Schema(
_('The Port binding profile. If specified, must contain switch_id '
'(only a MAC address or an OpenFlow based datapath_id of the '
'switch are accepted in this field) and port_id (identifier of '
'the physical port on the switch to which node\'s port is '
'connected to) fields. switch_info is an optional string field '
'to be used to store any vendor-specific information.'),
type=attributes.Schema.MAP
),
PXE_ENABLED_ATTR: attributes.Schema(
_('Indicates whether PXE is enabled or disabled on the Port.'),
type=attributes.Schema.BOOLEAN
),
PHYSICAL_NETWORK_ATTR: attributes.Schema(
_('The name of the physical network to which a port is connected. '
'May be empty.'),
type=attributes.Schema.STRING
),
INTERNAL_INFO_ATTR: attributes.Schema(
_('Internal metadata set and stored by the Port. This field is '
'read-only.'),
type=attributes.Schema.MAP
),
EXTRA_ATTR: attributes.Schema(
_('A set of one or more arbitrary metadata key and value pairs.'),
type=attributes.Schema.MAP
),
IS_SMARTNIC_ATTR: attributes.Schema(
_('Indicates whether the Port is a Smart NIC port.'),
type=attributes.Schema.BOOLEAN
)}
properties_schema = {
NODE: properties.Schema(
properties.Schema.STRING,
_('UUID or name of the Node this resource belongs to.'),
constraints=[
constraints.CustomConstraint('ironic.node')
],
required=True,
update_allowed=True
),
ADDRESS: properties.Schema(
properties.Schema.STRING,
_('Physical hardware address of this network Port, typically the '
'hardware MAC address.'),
required=True,
update_allowed=True
),
PORTGROUP: properties.Schema(
properties.Schema.STRING,
_('UUID or name of the Portgroup this resource belongs to.'),
constraints=[
constraints.CustomConstraint('ironic.portgroup')
],
update_allowed=True,
),
LOCAL_LINK_CONNECTION: properties.Schema(
properties.Schema.MAP,
_('The Port binding profile. If specified, must contain switch_id '
'(only a MAC address or an OpenFlow based datapath_id of the '
'switch are accepted in this field) and port_id (identifier of '
'the physical port on the switch to which node\'s port is '
'connected to) fields. switch_info is an optional string field '
'to be used to store any vendor-specific information.'),
update_allowed=True,
),
PXE_ENABLED: properties.Schema(
properties.Schema.BOOLEAN,
_('Indicates whether PXE is enabled or disabled on the Port.'),
update_allowed=True,
),
PHYSICAL_NETWORK: properties.Schema(
properties.Schema.STRING,
_('The name of the physical network to which a port is connected. '
'May be empty.'),
update_allowed=True,
),
EXTRA: properties.Schema(
properties.Schema.MAP,
_('A set of one or more arbitrary metadata key and value pairs.'),
update_allowed=True,
),
IS_SMARTNIC: properties.Schema(
properties.Schema.BOOLEAN,
_('Indicates whether the Port is a Smart NIC port.'),
update_allowed=True,
)
}
def translation_rules(self, props):
return [
translation.TranslationRule(
props,
translation.TranslationRule.RESOLVE,
[self.NODE],
client_plugin=self.client_plugin('ironic'),
finder='get_node'),
translation.TranslationRule(
props,
translation.TranslationRule.RESOLVE,
[self.PORTGROUP],
client_plugin=self.client_plugin('ironic'),
finder='get_portgroup'),
]
def _resolve_attribute(self, name):
if self.resource_id is None:
return
port = self.client().port.get(self.resource_id)
return getattr(port, name, None)
def _check_supported(self, properties):
# TODO(ricolin) Implement version support in property schema.
for k, v in self.PROPERTIES_MIN_SUPPORT_VERSION:
if k in properties and properties[k] is not None and (
self.client_plugin().max_microversion < v
):
raise exception.NotSupported(
feature="OS::Ironic::Port with %s property" % k)
def handle_create(self):
args = dict(self.properties.items())
self._check_supported(args)
args['node_uuid'] = args.pop(self.NODE)
if self.PORTGROUP in args:
args['portgroup_uuid'] = args.pop(self.PORTGROUP)
port = self.client().port.create(**args)
self.resource_id_set(port.uuid)
return port.uuid
def check_create_complete(self, id):
try:
self.client().port.get(id)
except Exception as exc:
self.client_plugin().ignore_not_found(exc)
return False
return True
def handle_update(self, json_snippet, tmpl_diff, prop_diff):
if prop_diff:
self._check_supported(prop_diff)
if self.NODE in prop_diff:
prop_diff['node_uuid'] = prop_diff.pop(self.NODE)
if self.PORTGROUP in prop_diff:
prop_diff['portgroup_uuid'] = prop_diff.pop(self.PORTGROUP)
patch = [{'op': 'replace', 'path': '/' + k, 'value': v}
for k, v in prop_diff.items()]
self.client().port.update(self.resource_id, patch)
return self.resource_id, prop_diff
def check_delete_complete(self, id):
if not id:
return True
try:
self.client().port.get(id)
except Exception as exc:
self.client_plugin().ignore_not_found(exc)
return True
return False
def resource_mapping():
return {
'OS::Ironic::Port': Port,
}

View File

@ -0,0 +1,275 @@
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import copy
from ironicclient.common.apiclient import exceptions as ic_exc
import mock
from oslo_config import cfg
from heat.common import exception
from heat.common import template_format
from heat.engine.clients.os import ironic as ic
from heat.engine import resource
from heat.engine.resources.openstack.ironic import port
from heat.engine import scheduler
from heat.engine import template
from heat.tests import common
from heat.tests import utils
cfg.CONF.import_opt('max_ironic_api_microversion', 'heat.common.config')
port_template = '''
heat_template_version: rocky
resources:
test_port:
type: OS::Ironic::Port
properties:
node: node_1
address: 52:54:00:4d:e1:5e
portgroup: pg1
local_link_connection:
switch_info: brbm
port_id: ovs-node-1i1
switch_id: 70:4d:7b:88:ff:3a
pxe_enabled: true
physical_network: fake_phy_net
extra: {}
is_smartnic: false
'''
min_port_template = '''
heat_template_version: ocata
resources:
test_port:
type: OS::Ironic::Port
properties:
node: node_2
address: 54:54:00:4d:e1:5e
'''
RESOURCE_TYPE = 'OS::Ironic::Port'
class TestIronicPort(common.HeatTestCase):
def setUp(self):
super(TestIronicPort, self).setUp()
cfg.CONF.set_override('max_ironic_api_microversion', 1.11)
cfg.CONF.set_override('action_retry_limit', 0)
self.fake_node_name = 'node_1'
self.fake_portgroup_name = 'pg1'
self.resource_id = '9cc6fd32-f711-4e1f-a82d-59e6ae074e95'
self.fake_name = 'test_port'
self.fake_address = u'52:54:00:4d:e1:5e'
self.fake_node_uuid = u'22767a68-a7f2-45fe-bc08-335a83e2b919'
self.fake_portgroup_uuid = '92972f88-a1e7-490f-866c-b6704d65c4de'
self.fake_local_link_connection = {'switch_info': 'brbm',
'port_id': 'ovs-node-1i1',
'switch_id': '70:4d:7b:88:ff:3a'}
self.fake_internal_info = {'foo': 'bar'}
self.fake_pxe_enabled = True
self.fake_physical_network = 'fake_phy_net'
self.fake_internal_info = {}
self.fake_extra = {}
self.fake_is_smartnic = False
resource._register_class(RESOURCE_TYPE, port.Port)
t = template_format.parse(port_template)
self.stack = utils.parse_stack(t)
resource_defns = self.stack.t.resource_definitions(self.stack)
self.rsrc_defn = resource_defns[self.fake_name]
self.client = mock.Mock()
self.patchobject(port.Port, 'client', return_value=self.client)
self.m_fgn = self.patchobject(ic.IronicClientPlugin,
'get_node')
self.m_fgpg = self.patchobject(ic.IronicClientPlugin,
'get_portgroup')
self.m_fgn.return_value = self.fake_node_uuid
self.m_fgpg.return_value = self.fake_portgroup_uuid
self._mock_get_client()
def _mock_get_client(self):
value = mock.MagicMock(
address=self.fake_address,
node_uuid=self.fake_node_uuid,
portgroup_uuid=self.fake_portgroup_uuid,
local_link_connection=self.fake_local_link_connection,
pxe_enabled=self.fake_pxe_enabled,
physical_network=self.fake_physical_network,
internal_info=self.fake_internal_info,
extra=self.fake_extra,
is_smartnic=self.fake_is_smartnic,
uuid=self.resource_id,
)
value.to_dict.return_value = value.__dict__
self.client.port.get.return_value = value
def _create_resource(self, name, snippet, stack, get_exception=None):
value = mock.MagicMock(uuid=self.resource_id)
self.client.port.create.return_value = value
get_rv = mock.MagicMock()
if get_exception:
self.client.port.get.side_effect = get_exception
else:
self.client.port.get.return_value = get_rv
p = port.Port(name, snippet, stack)
return p
def test_port_create(self):
b = self._create_resource('port', self.rsrc_defn, self.stack)
# validate the properties
self.assertEqual(
self.fake_node_name,
b.properties.get(port.Port.NODE))
self.assertEqual(
self.fake_address,
b.properties.get(port.Port.ADDRESS))
self.assertEqual(
self.fake_portgroup_name,
b.properties.get(port.Port.PORTGROUP))
self.assertEqual(
self.fake_local_link_connection,
b.properties.get(port.Port.LOCAL_LINK_CONNECTION))
self.assertEqual(
self.fake_pxe_enabled,
b.properties.get(port.Port.PXE_ENABLED))
self.assertEqual(
self.fake_physical_network,
b.properties.get(port.Port.PHYSICAL_NETWORK))
self.assertEqual(
self.fake_extra,
b.properties.get(port.Port.EXTRA))
self.assertEqual(
self.fake_is_smartnic,
b.properties.get(port.Port.IS_SMARTNIC))
scheduler.TaskRunner(b.create)()
self.assertEqual(self.resource_id, b.resource_id)
expected = [mock.call(self.fake_node_name),
mock.call(self.fake_node_uuid)]
self.assertEqual(expected, self.m_fgn.call_args_list)
expected = [mock.call(self.fake_portgroup_name),
mock.call(self.fake_portgroup_uuid)]
self.assertEqual(expected, self.m_fgpg.call_args_list)
self.client.port.create.assert_called_once_with(
address=self.fake_address,
extra=self.fake_extra,
is_smartnic=self.fake_is_smartnic,
local_link_connection=self.fake_local_link_connection,
node_uuid=self.fake_node_uuid,
physical_network=self.fake_physical_network,
portgroup_uuid=self.fake_portgroup_uuid,
pxe_enabled=self.fake_pxe_enabled)
def _property_not_supported(self, property_name, version):
t = template_format.parse(min_port_template)
new_t = copy.deepcopy(t)
new_t['resources'][self.fake_name]['properties'][
property_name] = self.rsrc_defn._properties[property_name]
rsrc_defns = template.Template(new_t).resource_definitions(
self.stack)
new_port = rsrc_defns[self.fake_name]
p = self._create_resource('port-with-%s' % property_name,
new_port, self.stack)
p.client_plugin().max_microversion = version - 0.01
feature = "OS::Ironic::Port with %s property" % property_name
err = self.assertRaises(exception.ResourceFailure,
scheduler.TaskRunner(p.create))
self.assertEqual("NotSupported: resources.port-with-%(key)s: "
"%(feature)s is not supported." % {
'feature': feature, 'key': property_name},
str(err))
def test_port_create_with_pxe_enabled_not_supported(self):
self._property_not_supported(port.Port.PXE_ENABLED, 1.19)
def test_port_create_with_local_link_connection_not_supported(self):
self._property_not_supported(port.Port.LOCAL_LINK_CONNECTION, 1.19)
def test_port_create_with_portgroup_not_supported(self):
self._property_not_supported(port.Port.PORTGROUP, 1.24)
def test_port_create_with_physical_network_not_supported(self):
self._property_not_supported(port.Port.PHYSICAL_NETWORK, 1.34)
def test_port_create_with_is_smartnic_not_supported(self):
self._property_not_supported(port.Port.IS_SMARTNIC, 1.53)
def test_port_check_create_complete(self):
b = self._create_resource('port', self.rsrc_defn, self.stack)
self.assertTrue(b.check_create_complete(self.resource_id))
def test_port_check_create_complete_with_not_found(self):
b = self._create_resource('port', self.rsrc_defn, self.stack,
get_exception=ic_exc.NotFound)
self.assertFalse(b.check_create_complete(self.resource_id))
def test_port_check_create_complete_with_non_not_found_exception(self):
b = self._create_resource('port', self.rsrc_defn, self.stack,
get_exception=ic_exc.Conflict())
exc = self.assertRaises(ic_exc.Conflict, b.check_create_complete,
self.resource_id)
self.assertIn('Conflict', str(exc))
def _port_update(self, exc_msg=None):
b = self._create_resource('port', self.rsrc_defn, self.stack)
scheduler.TaskRunner(b.create)()
if exc_msg:
self.client.port.update.side_effect = ic_exc.Conflict(exc_msg)
t = template_format.parse(port_template)
new_t = copy.deepcopy(t)
new_extra = {'foo': 'bar'}
m_pg = mock.Mock(extra=new_extra)
self.client.port.get.return_value = m_pg
new_t['resources'][self.fake_name]['properties']['extra'] = new_extra
rsrc_defns = template.Template(new_t).resource_definitions(self.stack)
new_port = rsrc_defns[self.fake_name]
if exc_msg:
exc = self.assertRaises(
exception.ResourceFailure,
scheduler.TaskRunner(b.update, new_port))
self.assertIn(exc_msg, str(exc))
else:
scheduler.TaskRunner(b.update, new_port)()
self.client.port.update.assert_called_once_with(
self.resource_id,
[{'op': 'replace', 'path': '/extra', 'value': new_extra}])
def test_port_update(self):
self._port_update()
def test_port_update_failed(self):
exc_msg = ("Port 9cc6fd32-f711-4e1f-a82d-59e6ae074e95 can not have "
"any connectivity attributes (pxe_enabled, portgroup_id, "
"physical_network, local_link_connection) updated unless "
"node 9ccee9ec-92a5-4580-9242-82eb7f454d3f is in a enroll, "
"inspecting, inspect wait, manageable state or in "
"maintenance mode.")
self._port_update(exc_msg)
def test_port_check_delete_complete_with_no_id(self):
b = self._create_resource('port', self.rsrc_defn, self.stack)
self.assertTrue(b.check_delete_complete(None))
def test_port_check_delete_complete_with_not_found(self):
b = self._create_resource('port', self.rsrc_defn, self.stack,
get_exception=ic_exc.NotFound)
self.assertTrue(b.check_delete_complete(self.resource_id))
def test_port_check_delete_complete_with_exception(self):
b = self._create_resource('port', self.rsrc_defn, self.stack,
get_exception=ic_exc.Conflict())
exc = self.assertRaises(ic_exc.Conflict,
b.check_delete_complete, self.resource_id)
self.assertIn('Conflict', str(exc))

View File

@ -0,0 +1,4 @@
---
features:
- |
New resource type ``OS::Ironic::Port`` is now supported in orchestration service.