diff --git a/heat/engine/resources/openstack/ironic/__init__.py b/heat/engine/resources/openstack/ironic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/heat/engine/resources/openstack/ironic/port.py b/heat/engine/resources/openstack/ironic/port.py new file mode 100644 index 0000000000..1b98040a52 --- /dev/null +++ b/heat/engine/resources/openstack/ironic/port.py @@ -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, + } diff --git a/heat/tests/openstack/ironic/test_port.py b/heat/tests/openstack/ironic/test_port.py new file mode 100644 index 0000000000..ac603ea955 --- /dev/null +++ b/heat/tests/openstack/ironic/test_port.py @@ -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)) diff --git a/releasenotes/notes/support-ironic-port-resource-type-304284a7c508d5d5.yaml b/releasenotes/notes/support-ironic-port-resource-type-304284a7c508d5d5.yaml new file mode 100644 index 0000000000..169082aa3f --- /dev/null +++ b/releasenotes/notes/support-ironic-port-resource-type-304284a7c508d5d5.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + New resource type ``OS::Ironic::Port`` is now supported in orchestration service.