From 20b7c4f014e2bea921f294742096095380468475 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Fri, 23 Jan 2026 17:11:49 -0600 Subject: [PATCH] feat(ironic): make updating ironic easier by following our branch We have a stable branch that we push patches into. This makes it easier for us to follow our patch series by installing Ironic from our branch. --- containers/ironic/Dockerfile | 19 +- containers/ironic/{patches => }/README.md | 17 +- .../ironic/ironic-console-pod.yaml.template | 45 - ...cal_network-to-neutron-from-the-bare.patch | 212 --- ...-Add-SKU-field-to-Redfish-inspection.patch | 86 -- ...-redfish-inspect-system-product-name.patch | 48 - ...ing-purposes-ignore-ports-with-categ.patch | 53 - ...d-mac-addr-interfaces-in-redfish-ins.patch | 115 -- ...-data-collection-support-to-the-Redf.patch | 515 ------- ...-collect-for-DRAC-Redfish-inspection.patch | 327 ----- ...netes-provider-for-console-container.patch | 1200 ----------------- containers/ironic/patches/series | 10 - 12 files changed, 21 insertions(+), 2626 deletions(-) rename containers/ironic/{patches => }/README.md (85%) delete mode 100644 containers/ironic/ironic-console-pod.yaml.template delete mode 100644 containers/ironic/patches/0001-pass-along-physical_network-to-neutron-from-the-bare.patch delete mode 100644 containers/ironic/patches/0002-Add-SKU-field-to-Redfish-inspection.patch delete mode 100644 containers/ironic/patches/0003-fix-redfish-inspect-system-product-name.patch delete mode 100644 containers/ironic/patches/0003-hack-for-scheduling-purposes-ignore-ports-with-categ.patch delete mode 100644 containers/ironic/patches/0004-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch delete mode 100644 containers/ironic/patches/0005-Add-Redfish-LLDP-data-collection-support-to-the-Redf.patch delete mode 100644 containers/ironic/patches/0006-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch delete mode 100644 containers/ironic/patches/0007-Add-a-kubernetes-provider-for-console-container.patch delete mode 100644 containers/ironic/patches/series diff --git a/containers/ironic/Dockerfile b/containers/ironic/Dockerfile index 4d06e4115..db85e1a96 100644 --- a/containers/ironic/Dockerfile +++ b/containers/ironic/Dockerfile @@ -5,27 +5,35 @@ FROM quay.io/airshipit/ironic:${OPENSTACK_VERSION}-ubuntu_noble AS build COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -COPY python/ironic-understack /src/understack/ironic-understack -COPY python/understack-flavor-matcher /src/understack/understack-flavor-matcher - RUN apt-get update && \ apt-get install -y --no-install-recommends \ + git \ patch \ - quilt \ && apt-get clean && rm -rf /var/lib/apt/lists/* +# clone source and patch it +# renovate: name=openstack/ironic repo=https://github.com/rackerlabs/ironic.git branch=understack/2025.2 +ARG IRONIC_GIT_REF=3856f2183a4e568eb4f05aa561c8626eb80241c0 +ADD --keep-git-dir=true https://github.com/openstack/ironic.git#${IRONIC_GIT_REF} /src/ironic +RUN git -C /src/ironic fetch --unshallow + +COPY python/ironic-understack /src/understack/ironic-understack +COPY python/understack-flavor-matcher /src/understack/understack-flavor-matcher + ARG OPENSTACK_VERSION="required_argument" ARG NOVNC_VERSION=1.6.0 RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install \ + --upgrade \ --constraint https://releases.openstack.org/constraints/upper/${OPENSTACK_VERSION} \ + /src/ironic \ /src/understack/ironic-understack \ /src/understack/understack-flavor-matcher \ proliantutils==2.16.3 COPY containers/ironic/patches /tmp/patches/ RUN cd /var/lib/openstack/lib/python3.12/site-packages && \ - QUILT_PATCHES=/tmp/patches quilt push -a + patch -p1 < /tmp/patches/0001-Solve-IPMI-call-issue-results-in-UTF-8-format-error-.patch # download and unpack novnc RUN \ @@ -36,7 +44,6 @@ RUN \ mkdir -p /usr/share/novnc/ && \ wget -q -O "$DEST_FILE" "$URL" && \ tar -C /usr/share/novnc/ --strip-components=1 -zxvf "${DEST_FILE}" "$PFX/app" "$PFX/core" "$PFX/vendor" "$PFX/vnc.html" -COPY containers/ironic/ironic-console-pod.yaml.template /var/lib/openstack/lib/python3.12/site-packages/ironic/console/container/ ARG OPENSTACK_VERSION="required_argument" FROM quay.io/airshipit/ironic:${OPENSTACK_VERSION}-ubuntu_noble AS final diff --git a/containers/ironic/patches/README.md b/containers/ironic/README.md similarity index 85% rename from containers/ironic/patches/README.md rename to containers/ironic/README.md index bc967df32..eab32a0f6 100644 --- a/containers/ironic/patches/README.md +++ b/containers/ironic/README.md @@ -16,15 +16,6 @@ git fetch rackerlabs git checkout --track rackerlabs/understack/2025.2 ``` -## So to generate this series of patches for 2025.2 for example: - -```bash -git checkout understack/2025.2 -git format-patch stable/2025.2 -o PATH/TO/THIS/DIR -``` - -Now update the `series` file for any new patches. - ## Adding patches is done via `git cherry-pick` ```bash @@ -49,3 +40,11 @@ git checkout understack/2025.2 git rebase stable/2025.2 git push rackerlabs understack/2025.2 ``` + +## Updating the container + +```bash +git checkout understack/2025.2 +git show +# ensure the git-ish in the Dockerfile matches +``` diff --git a/containers/ironic/ironic-console-pod.yaml.template b/containers/ironic/ironic-console-pod.yaml.template deleted file mode 100644 index 0c6a5e38b..000000000 --- a/containers/ironic/ironic-console-pod.yaml.template +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: "ironic-console-{{ uuid }}" - namespace: openstack - labels: - app: ironic - component: ironic-console - conductor: "{{ conductor }}" -stringData: - app-info: '{{ app_info }}' ---- -apiVersion: v1 -kind: Pod -metadata: - name: "ironic-console-{{ uuid }}" - namespace: openstack - labels: - app: ironic - component: ironic-console - conductor: "{{ conductor }}" -spec: - containers: - - name: x11vnc - image: "{{ image }}" - imagePullPolicy: Always - ports: - - containerPort: 5900 - resources: - requests: - cpu: 250m - memory: 256Mi - limits: - cpu: 1500m - memory: 1024Mi - env: - - name: APP - value: "{{ app }}" - - name: READ_ONLY - value: "{{ read_only }}" - - name: APP_INFO - valueFrom: - secretKeyRef: - name: "ironic-console-{{ uuid }}" - key: app-info diff --git a/containers/ironic/patches/0001-pass-along-physical_network-to-neutron-from-the-bare.patch b/containers/ironic/patches/0001-pass-along-physical_network-to-neutron-from-the-bare.patch deleted file mode 100644 index d1d46a67f..000000000 --- a/containers/ironic/patches/0001-pass-along-physical_network-to-neutron-from-the-bare.patch +++ /dev/null @@ -1,212 +0,0 @@ -From 1b26fa5dad6ce2ad8ac959188e98cd951aee099d Mon Sep 17 00:00:00 2001 -From: Doug Goldstein -Date: Wed, 22 Oct 2025 12:58:41 -0500 -Subject: [PATCH 1/7] pass along physical_network to neutron from the baremetal - port - -When plugging a baremetal port in using the 'neutron' interface, send -the 'physical_network' value of the baremetal port to Neutron as part of the -binding_profile for the port. This can be useful for VXLAN underlay -connected machines where the networks in Neutron are VXLAN networks -which then have segments on them that are VLAN based segments which bind -the VNI to a VLAN for attachment for the node to connect to the VNI. - -Ref: https://bugs.launchpad.net/ovn-bgp-agent/+bug/2017890 -Ref: https://bugs.launchpad.net/neutron/+bug/2114451 -Ref: https://review.opendev.org/c/openstack/neutron-specs/+/952166 - -Partial-Bug: #2105855 -Assisted-by: Claude Code 2.0 -Change-Id: I6e0185e203489676d530e6955929997f4871b8fa -Signed-off-by: Doug Goldstein ---- - ironic/common/neutron.py | 4 ++ - ironic/drivers/modules/network/common.py | 11 +++ - ironic/tests/unit/common/test_neutron.py | 38 ++++++++++ - .../drivers/modules/network/test_common.py | 71 +++++++++++++++++++ - ...ude-physical-network-8d8cbe17716d341a.yaml | 6 ++ - 5 files changed, 130 insertions(+) - create mode 100644 releasenotes/notes/neutron-port-binding-include-physical-network-8d8cbe17716d341a.yaml - -diff --git a/ironic/common/neutron.py b/ironic/common/neutron.py -index b53c2c7e8..b2ba7749b 100644 ---- a/ironic/common/neutron.py -+++ b/ironic/common/neutron.py -@@ -371,6 +371,10 @@ def add_ports_to_network(task, network_uuid, security_groups=None): - binding_profile['vtep-logical-switch'] = vtep_logical_switch - binding_profile['vtep-physical-switch'] = vtep_physical_switch - -+ # Include physical_network if available -+ if ironic_port.physical_network: -+ binding_profile['physical_network'] = ironic_port.physical_network -+ - update_port_attrs['binding:profile'] = binding_profile - - if not ironic_port.pxe_enabled: -diff --git a/ironic/drivers/modules/network/common.py b/ironic/drivers/modules/network/common.py -index 04413663c..0e3b9b0b0 100644 ---- a/ironic/drivers/modules/network/common.py -+++ b/ironic/drivers/modules/network/common.py -@@ -272,6 +272,17 @@ def plug_port_to_tenant_network(task, port_like_obj, client=None): - binding_profile = {'local_link_information': local_link_info} - if local_group_info: - binding_profile['local_group_information'] = local_group_info -+ -+ # Include physical_network if available -+ if isinstance(port_like_obj, objects.Portgroup): -+ # For portgroups, get physical_network from the first port -+ pg_ports = [p for p in task.ports -+ if p.portgroup_id == port_like_obj.id] -+ if pg_ports and pg_ports[0].physical_network: -+ binding_profile['physical_network'] = pg_ports[0].physical_network -+ elif port_like_obj.physical_network: -+ binding_profile['physical_network'] = port_like_obj.physical_network -+ - port_attrs['binding:profile'] = binding_profile - - if client_id_opt: -diff --git a/ironic/tests/unit/common/test_neutron.py b/ironic/tests/unit/common/test_neutron.py -index 406e42a7e..4bc0140b9 100644 ---- a/ironic/tests/unit/common/test_neutron.py -+++ b/ironic/tests/unit/common/test_neutron.py -@@ -329,6 +329,44 @@ class TestNeutronNetworkActions(db_base.DbTestCase): - self._test_add_ports_to_network(is_client_id=False, - security_groups=sg_ids) - -+ @mock.patch.object(neutron, 'update_neutron_port', autospec=True) -+ def test_add_ports_to_network_with_physical_network(self, update_mock): -+ # Test that physical_network is included in binding:profile -+ self.node.network_interface = 'neutron' -+ self.node.save() -+ port = self.ports[0] -+ port.physical_network = 'physnet1' -+ port.save() -+ -+ expected_create_attrs = { -+ 'network_id': self.network_uuid, -+ 'admin_state_up': True, -+ 'binding:vnic_type': 'baremetal', -+ 'device_id': self.node.uuid -+ } -+ expected_update_attrs = { -+ 'device_owner': 'baremetal:none', -+ 'binding:host_id': self.node.uuid, -+ 'mac_address': port.address, -+ 'binding:profile': { -+ 'local_link_information': [port.local_link_connection], -+ 'physical_network': 'physnet1' -+ } -+ } -+ -+ self.client_mock.create_port.return_value = self.neutron_port -+ update_mock.return_value = self.neutron_port -+ expected = {port.uuid: self.neutron_port['id']} -+ -+ with task_manager.acquire(self.context, self.node.uuid) as task: -+ ports = neutron.add_ports_to_network(task, self.network_uuid) -+ self.assertEqual(expected, ports) -+ self.client_mock.create_port.assert_called_once_with( -+ **expected_create_attrs) -+ update_mock.assert_called_once_with( -+ self.context, self.neutron_port['id'], -+ expected_update_attrs) -+ - @mock.patch.object(neutron, 'update_neutron_port', autospec=True) - def test__add_ip_addresses_for_ipv6_stateful(self, mock_update): - subnet_id = uuidutils.generate_uuid() -diff --git a/ironic/tests/unit/drivers/modules/network/test_common.py b/ironic/tests/unit/drivers/modules/network/test_common.py -index 3c78a3ca1..2365dbcdd 100644 ---- a/ironic/tests/unit/drivers/modules/network/test_common.py -+++ b/ironic/tests/unit/drivers/modules/network/test_common.py -@@ -489,6 +489,77 @@ class TestCommonFunctions(db_base.DbTestCase): - nclient, self.vif_id, 'ACTIVE', fail_on_binding_failure=True) - self.assertTrue(mock_update.called) - -+ @mock.patch.object(neutron_common, 'update_neutron_port', autospec=True) -+ @mock.patch.object(neutron_common, 'wait_for_port_status', autospec=True) -+ @mock.patch.object(neutron_common, 'get_client', autospec=True) -+ def test_plug_port_to_tenant_network_with_physical_network( -+ self, mock_gc, wait_mock_status, mock_update): -+ # Test that physical_network is included in binding:profile for port -+ nclient = mock.MagicMock() -+ mock_gc.return_value = nclient -+ self.port.internal_info = {common.TENANT_VIF_KEY: self.vif_id} -+ self.port.physical_network = 'physnet1' -+ self.port.save() -+ -+ expected_attrs = { -+ 'binding:vnic_type': neutron_common.VNIC_BAREMETAL, -+ 'binding:host_id': self.node.uuid, -+ 'mac_address': self.port.address, -+ 'binding:profile': { -+ 'local_link_information': [self.port.local_link_connection], -+ 'physical_network': 'physnet1' -+ } -+ } -+ -+ with task_manager.acquire(self.context, self.node.id) as task: -+ common.plug_port_to_tenant_network(task, self.port) -+ mock_update.assert_called_once_with( -+ task.context, self.vif_id, expected_attrs) -+ -+ @mock.patch.object(neutron_common, 'update_neutron_port', autospec=True) -+ @mock.patch.object(neutron_common, 'wait_for_port_status', autospec=True) -+ @mock.patch.object(neutron_common, 'get_client', autospec=True) -+ def test_plug_portgroup_to_tenant_network_with_physical_network( -+ self, mock_gc, wait_mock_status, mock_update): -+ # Test that physical_network is included in binding:profile for -+ # a portgroup -+ nclient = mock.MagicMock() -+ mock_gc.return_value = nclient -+ pg = obj_utils.create_test_portgroup( -+ self.context, node_id=self.node.id, address='00:54:00:cf:2d:01') -+ port1 = obj_utils.create_test_port( -+ self.context, node_id=self.node.id, address='52:54:00:cf:2d:01', -+ portgroup_id=pg.id, uuid=uuidutils.generate_uuid(), -+ physical_network='physnet1') -+ port2 = obj_utils.create_test_port( -+ self.context, node_id=self.node.id, address='52:54:00:cf:2d:02', -+ portgroup_id=pg.id, uuid=uuidutils.generate_uuid(), -+ physical_network='physnet1') -+ pg.internal_info = {common.TENANT_VIF_KEY: self.vif_id} -+ pg.save() -+ -+ expected_attrs = { -+ 'binding:vnic_type': neutron_common.VNIC_BAREMETAL, -+ 'binding:host_id': self.node.uuid, -+ 'mac_address': pg.address, -+ 'binding:profile': { -+ 'local_link_information': [port1.local_link_connection, -+ port2.local_link_connection], -+ 'local_group_information': { -+ 'id': pg.uuid, -+ 'name': pg.name, -+ 'bond_mode': pg.mode, -+ 'bond_properties': {} -+ }, -+ 'physical_network': 'physnet1' -+ } -+ } -+ -+ with task_manager.acquire(self.context, self.node.id) as task: -+ common.plug_port_to_tenant_network(task, pg) -+ mock_update.assert_called_once_with( -+ task.context, self.vif_id, expected_attrs) -+ - - class TestVifPortIDMixin(db_base.DbTestCase): - -diff --git a/releasenotes/notes/neutron-port-binding-include-physical-network-8d8cbe17716d341a.yaml b/releasenotes/notes/neutron-port-binding-include-physical-network-8d8cbe17716d341a.yaml -new file mode 100644 -index 000000000..1a556d812 ---- /dev/null -+++ b/releasenotes/notes/neutron-port-binding-include-physical-network-8d8cbe17716d341a.yaml -@@ -0,0 +1,6 @@ -+--- -+features: -+ - | -+ When plugging a baremetal port in using the 'neutron' interface, send -+ the 'physical_network' value of the baremetal port to Neutron as part of the -+ binding_profile for the port. --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0002-Add-SKU-field-to-Redfish-inspection.patch b/containers/ironic/patches/0002-Add-SKU-field-to-Redfish-inspection.patch deleted file mode 100644 index 29aa13f6b..000000000 --- a/containers/ironic/patches/0002-Add-SKU-field-to-Redfish-inspection.patch +++ /dev/null @@ -1,86 +0,0 @@ -From e7be4c17b8d9119b3beaa926853feb3d6076e475 Mon Sep 17 00:00:00 2001 -From: Nidhi Rai -Date: Thu, 11 Sep 2025 17:05:22 +0530 -Subject: [PATCH 2/7] Add SKU field to Redfish inspection - -Collect SKU (Service Tag) from Redfish ComputerSystem -and include it in system_vendor inventory data. - -This enables downstream tools like Nautobot to access -the Dell Service Tag or other vendor SKU information -during hardware inspection. - -The change: -- Safely checks for SKU attribute existence -- Adds SKU to system_vendor dictionary -- Handles missing SKU gracefully -- Includes unit test coverage - -Change-Id: I6623fd33b356d6149001c43a7179297a7c8568d8 -Signed-off-by: Nidhi Rai ---- - ironic/drivers/modules/redfish/inspect.py | 6 ++++++ - .../unit/drivers/modules/redfish/test_inspect.py | 11 +++++++++++ - 2 files changed, 17 insertions(+) - -diff --git a/ironic/drivers/modules/redfish/inspect.py b/ironic/drivers/modules/redfish/inspect.py -index df1ff9a91..69f0f0c09 100644 ---- a/ironic/drivers/modules/redfish/inspect.py -+++ b/ironic/drivers/modules/redfish/inspect.py -@@ -158,6 +158,12 @@ class RedfishInspect(base.InspectInterface): - if system.manufacturer: - system_vendor['manufacturer'] = str(system.manufacturer) - -+ if system.sku: -+ system_vendor['sku'] = str(system.sku) -+ -+ if system.uuid: -+ system_vendor['system_uuid'] = str(system.uuid) -+ - if system_vendor: - inventory['system_vendor'] = system_vendor - -diff --git a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -index 733a2beab..162998e33 100644 ---- a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -+++ b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -@@ -140,6 +140,9 @@ class RedfishInspectTestCase(db_base.DbTestCase): - system_mock.manufacturer = 'Sushy Emulator' - - system_mock.model = 'PowerEdge R1234' -+ system_mock.sku = 'DELL123456' -+ -+ system_mock.uuid = '12345678-1234-1234-1234-12345' - - return system_mock - -@@ -187,12 +190,18 @@ class RedfishInspectTestCase(db_base.DbTestCase): - expected_product_name = 'PowerEdge R1234' - expected_serial_number = '123456' - expected_manufacturer = 'Sushy Emulator' -+ expected_sku = 'DELL123456' -+ expected_system_uuid = '12345678-1234-1234-1234-12345' - self.assertEqual(expected_product_name, - system_vendor['product_name']) - self.assertEqual(expected_serial_number, - system_vendor['serial_number']) - self.assertEqual(expected_manufacturer, - system_vendor['manufacturer']) -+ self.assertEqual(expected_sku, -+ system_vendor['sku']) -+ self.assertEqual(expected_system_uuid, -+ system_vendor['system_uuid']) - - expected_interfaces = [{'mac_address': '00:11:22:33:44:55', - 'name': 'NIC.Integrated.1-1'}, -@@ -702,6 +711,8 @@ class RedfishInspectTestCase(db_base.DbTestCase): - system_mock.model = None - system_mock.serial_number = None - system_mock.manufacturer = None -+ system_mock.sku = None -+ system_mock.uuid = None - - with task_manager.acquire(self.context, self.node.uuid, - shared=True) as task: --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0003-fix-redfish-inspect-system-product-name.patch b/containers/ironic/patches/0003-fix-redfish-inspect-system-product-name.patch deleted file mode 100644 index 3afa7963f..000000000 --- a/containers/ironic/patches/0003-fix-redfish-inspect-system-product-name.patch +++ /dev/null @@ -1,48 +0,0 @@ -From 7b1a65925b9462b0a84fe6e840ff0c90b3b19bfc Mon Sep 17 00:00:00 2001 -From: Doug Goldstein -Date: Mon, 15 Dec 2025 16:35:57 -0600 -Subject: [PATCH 3/8] fix redfish inspect system product name - -The intention of this code is to read the system product name which is -stored in the model field per the Redfish spec and not in the name field -which will always store the name of the object we are working with. This -results in the value always being 'System'. - -Closes-Bug: 2136233 -Change-Id: I375fbe27253d7965e458be7b147d5b72cffa4e89 -Signed-off-by: Doug Goldstein ---- - ironic/drivers/modules/redfish/inspect.py | 4 ++-- - .../redfish-inspect-product-name-bd537f417d35b254.yaml | 6 ++++++ - 2 files changed, 8 insertions(+), 2 deletions(-) - create mode 100644 releasenotes/notes/redfish-inspect-product-name-bd537f417d35b254.yaml - -diff --git a/ironic/drivers/modules/redfish/inspect.py b/ironic/drivers/modules/redfish/inspect.py -index 59c3ce2b0..69f0f0c09 100644 ---- a/ironic/drivers/modules/redfish/inspect.py -+++ b/ironic/drivers/modules/redfish/inspect.py -@@ -149,8 +149,8 @@ class RedfishInspect(base.InspectInterface): - inventory['interfaces'].append(iface) - - system_vendor = {} -- if system.name: -- system_vendor['product_name'] = str(system.name) -+ if system.model: -+ system_vendor['product_name'] = str(system.model) - - if system.serial_number: - system_vendor['serial_number'] = str(system.serial_number) -diff --git a/releasenotes/notes/redfish-inspect-product-name-bd537f417d35b254.yaml b/releasenotes/notes/redfish-inspect-product-name-bd537f417d35b254.yaml -new file mode 100644 -index 000000000..4b0b7c4b6 ---- /dev/null -+++ b/releasenotes/notes/redfish-inspect-product-name-bd537f417d35b254.yaml -@@ -0,0 +1,6 @@ -+--- -+fixes: -+ - | -+ When using the redfish inspection driver, the system product name in the -+ data was always the value 'System' due to the wrong field being read. -+ See `bug 2136233 `_ --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0003-hack-for-scheduling-purposes-ignore-ports-with-categ.patch b/containers/ironic/patches/0003-hack-for-scheduling-purposes-ignore-ports-with-categ.patch deleted file mode 100644 index e8efd93c4..000000000 --- a/containers/ironic/patches/0003-hack-for-scheduling-purposes-ignore-ports-with-categ.patch +++ /dev/null @@ -1,53 +0,0 @@ -From 0812e66dd4b52cfb917d2593a8f3efd67922b98a Mon Sep 17 00:00:00 2001 -From: Doug Goldstein -Date: Tue, 13 Jan 2026 11:08:04 -0600 -Subject: [PATCH 3/7] hack: for scheduling purposes ignore ports with category - storage - -We want to ignore ports that are in category storage for being eligible -for attachment. This is a hack until TBN arrives and we can utilize -that. - -Change-Id: Ibc211ed8e6b9e5933ff657605933efd4c84fb77e -Signed-off-by: Doug Goldstein ---- - ironic/drivers/modules/network/common.py | 17 +++++++++++++++++ - 1 file changed, 17 insertions(+) - -diff --git a/ironic/drivers/modules/network/common.py b/ironic/drivers/modules/network/common.py -index 0e3b9b0b0..d19d6b0a1 100644 ---- a/ironic/drivers/modules/network/common.py -+++ b/ironic/drivers/modules/network/common.py -@@ -34,6 +34,21 @@ LOG = log.getLogger(__name__) - TENANT_VIF_KEY = 'tenant_vif_port_id' - - -+def _port_category_hack(port_like_obj): -+ """Hack until we get TBN.""" -+ -+ category = getattr(port_like_obj, "category", "") -+ if category == "network": -+ # if the category is "network", then we want this port -+ return True -+ elif category == "storage": -+ # if the category is "storage", we don't want this port -+ return False -+ else: -+ # if its unset, backwards compat and attempt to use the port -+ return True -+ -+ - def _vif_attached(port_like_obj, vif_id): - """Check if VIF is already attached to a port or portgroup. - -@@ -130,6 +145,8 @@ def _get_free_portgroups_and_ports(task, vif_id, physnets, vif_info={}): - continue - if not _is_port_physnet_allowed(p, physnets): - continue -+ if not _port_category_hack(p): -+ continue - if p.portgroup_id is None and not portgroup_uuid: - free_port_like_objs.append(p) - else: --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0004-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch b/containers/ironic/patches/0004-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch deleted file mode 100644 index 4451cb6fe..000000000 --- a/containers/ironic/patches/0004-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch +++ /dev/null @@ -1,115 +0,0 @@ -From 5fde08ae5631dfeb078ae40838b91c416a422f0f Mon Sep 17 00:00:00 2001 -From: Doug Goldstein -Date: Tue, 13 Jan 2026 11:49:05 -0600 -Subject: [PATCH 4/7] feat: skip invalid mac addr interfaces in redfish inspect - add speed - -Refactor the redfish interfaces inspection by moving it to its own -function, but preserving the behavior of not having an interfaces key in -the inspection data when it is empty. If the MAC is invalid for the -interface, skip it instead of adding it. Added the speed_mbps field to -match agent inspection. - -Change-Id: I60cdf6e22b4ae4773e4497f0e0f10d795ef2cb6c -Signed-off-by: Doug Goldstein ---- - ironic/drivers/modules/redfish/inspect.py | 35 +++++++++++++++---- - .../drivers/modules/redfish/test_inspect.py | 8 +++-- - 2 files changed, 35 insertions(+), 8 deletions(-) - -diff --git a/ironic/drivers/modules/redfish/inspect.py b/ironic/drivers/modules/redfish/inspect.py -index 69f0f0c09..9786c9f46 100644 ---- a/ironic/drivers/modules/redfish/inspect.py -+++ b/ironic/drivers/modules/redfish/inspect.py -@@ -14,6 +14,7 @@ Redfish Inspect Interface - """ - - from oslo_log import log -+from oslo_utils import netutils - from oslo_utils import units - import sushy - -@@ -141,12 +142,7 @@ class RedfishInspect(base.InspectInterface): - - inventory['disks'] = disks - -- if system.ethernet_interfaces and system.ethernet_interfaces.summary: -- inventory['interfaces'] = [] -- for eth in system.ethernet_interfaces.get_members(): -- iface = {'mac_address': eth.mac_address, -- 'name': eth.identity} -- inventory['interfaces'].append(iface) -+ inventory['interfaces'] = self._get_interface_info(task, system) - - system_vendor = {} - if system.model: -@@ -297,6 +293,33 @@ class RedfishInspect(base.InspectInterface): - """ - return None - -+ def _get_interface_info(self, task, system): -+ """Extract ethernet interface info.""" -+ -+ ret = [] -+ if not system.ethernet_interfaces: -+ return ret -+ -+ for eth in system.ethernet_interfaces.get_members(): -+ if not netutils.is_valid_mac(eth.mac_address): -+ LOG.warning(_("Ignoring NIC address '%(address)s' for " -+ "interface %(inf)s on node %(node)s because it " -+ "is not a valid MAC"), -+ {'address': eth.mac_address, -+ 'inf': eth.identity, -+ 'node': task.node.uuid}) -+ continue -+ intf = { -+ 'mac_address': eth.mac_address, -+ 'name': eth.identity -+ } -+ try: -+ intf['speed_mbps'] = int(eth.speed_mbps) -+ except Exception: -+ pass -+ ret.append(intf) -+ return ret -+ - def _get_processor_info(self, task, system): - # NOTE(JayF): Checking truthiness here is better than checking for None - # because if we have an empty list, we'll raise a -diff --git a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -index 162998e33..e8c3e01d7 100644 ---- a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -+++ b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -@@ -108,6 +108,7 @@ class RedfishInspectTestCase(db_base.DbTestCase): - spec=sushy.resources.system.ethernet_interface.EthernetInterface) - eth_interface_mock1.identity = 'NIC.Integrated.1-1' - eth_interface_mock1.mac_address = '00:11:22:33:44:55' -+ eth_interface_mock1.speed_mbps = 25000 - eth_interface_mock1.status.state = sushy.STATE_ENABLED - eth_interface_mock1.status.health = sushy.HEALTH_OK - -@@ -115,6 +116,7 @@ class RedfishInspectTestCase(db_base.DbTestCase): - spec=sushy.resources.system.ethernet_interface.EthernetInterface) - eth_interface_mock2.identity = 'NIC.Integrated.2-1' - eth_interface_mock2.mac_address = '66:77:88:99:AA:BB' -+ eth_interface_mock2.speed_mbps = 25000 - eth_interface_mock2.status.state = sushy.STATE_DISABLED - eth_interface_mock2.status.health = sushy.HEALTH_OK - -@@ -204,9 +206,11 @@ class RedfishInspectTestCase(db_base.DbTestCase): - system_vendor['system_uuid']) - - expected_interfaces = [{'mac_address': '00:11:22:33:44:55', -- 'name': 'NIC.Integrated.1-1'}, -+ 'name': 'NIC.Integrated.1-1', -+ 'speed_mbps': 25000}, - {'mac_address': '66:77:88:99:AA:BB', -- 'name': 'NIC.Integrated.2-1'}] -+ 'name': 'NIC.Integrated.2-1', -+ 'speed_mbps': 25000}] - self.assertEqual(expected_interfaces, - inventory['inventory']['interfaces']) - --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0005-Add-Redfish-LLDP-data-collection-support-to-the-Redf.patch b/containers/ironic/patches/0005-Add-Redfish-LLDP-data-collection-support-to-the-Redf.patch deleted file mode 100644 index c5903f23e..000000000 --- a/containers/ironic/patches/0005-Add-Redfish-LLDP-data-collection-support-to-the-Redf.patch +++ /dev/null @@ -1,515 +0,0 @@ -From 3f55e9dc63060cfa6ceed23f96f1f42dede6014f Mon Sep 17 00:00:00 2001 -From: Nidhi Rai -Date: Thu, 20 Nov 2025 19:01:05 +0530 -Subject: [PATCH 5/7] Add Redfish LLDP data collection support to the Redfish - inspection interface. - -- _collect_lldp_data(): Collects LLDP data from Redfish NetworkAdapter Ports via Sushy library, walking the Chassis/NetworkAdapter/Port hierarchy -- Integration with inspect_hardware(): LLDP collection is called during hardware inspection and results are stored in plugin_data['parsed_lldp'] - -The implementation supports standard Redfish LLDP data from Port.Ethernet.LLDPReceive fields and can be extended by vendor-specific implementations (like, Dell DRAC OEM endpoints) through method overriding. - -Change-Id: I25889b2a2eb8f6a2d796dfbeb598875a7c07b22c -Signed-off-by: Nidhi Rai ---- - ironic/drivers/modules/redfish/inspect.py | 192 ++++++++++++ - .../drivers/modules/redfish/test_inspect.py | 277 ++++++++++++++++++ - 2 files changed, 469 insertions(+) - -diff --git a/ironic/drivers/modules/redfish/inspect.py b/ironic/drivers/modules/redfish/inspect.py -index 9786c9f46..aa492ebc6 100644 ---- a/ironic/drivers/modules/redfish/inspect.py -+++ b/ironic/drivers/modules/redfish/inspect.py -@@ -182,6 +182,15 @@ class RedfishInspect(base.InspectInterface): - - plugin_data = {} - -+ # Collect LLDP data from Redfish NetworkAdapter Ports -+ # This method can be overridden by vendor-specific implementations -+ lldp_raw_data = self._collect_lldp_data(task, system) -+ if lldp_raw_data: -+ plugin_data['parsed_lldp'] = lldp_raw_data -+ LOG.info('Collected LLDP data for %(count)d interface(s) on ' -+ 'node %(node)s', -+ {'count': len(lldp_raw_data), 'node': task.node.uuid}) -+ - inspect_utils.run_inspection_hooks(task, inventory, plugin_data, - self.hooks, None) - inspect_utils.store_inspection_data(task.node, -@@ -342,3 +351,186 @@ class RedfishInspect(base.InspectInterface): - processor.instruction_set) or '' - - return cpu -+ -+ def _get_pcie_devices(self, pcie_devices_collection): -+ """Extract PCIe device information from Redfish collection. -+ -+ :param pcie_devices_collection: Redfish PCIe devices collection -+ :returns: List of PCIe device dictionaries -+ """ -+ # Return empty list if collection is None -+ if pcie_devices_collection is None: -+ return [] -+ -+ device_list = [] -+ -+ # Process each PCIe device -+ for pcie_device in pcie_devices_collection.get_members(): -+ # Skip devices that don't have functions -+ if (not hasattr(pcie_device, 'pcie_functions') -+ or not pcie_device.pcie_functions): -+ continue -+ -+ # Process each function on this device -+ for pcie_function in pcie_device.pcie_functions.get_members(): -+ function_info = self._extract_function_info(pcie_function) -+ if function_info: -+ device_list.append(function_info) -+ -+ return device_list -+ -+ def _extract_function_info(self, function): -+ """Extract information from a PCIe function. -+ -+ :param function: PCIe function object -+ :returns: Dictionary with function attributes -+ """ -+ info = {} -+ # Naming them same as in IPA for compatibility -+ # IPA has extra bus and numa_node_id which BMC doesn't have. -+ if function.device_class is not None: -+ info['class'] = str(function.device_class) -+ if function.device_id is not None: -+ info['product_id'] = function.device_id -+ if function.vendor_id is not None: -+ info['vendor_id'] = function.vendor_id -+ if function.subsystem_id is not None: -+ info['subsystem_id'] = function.subsystem_id -+ if function.subsystem_vendor_id is not None: -+ info['subsystem_vendor_id'] = function.subsystem_vendor_id -+ if function.revision_id is not None: -+ info['revision'] = function.revision_id -+ return info -+ -+ def _collect_lldp_data(self, task, system): -+ """Collect LLDP data from Redfish NetworkAdapter Ports. -+ -+ This method can be overridden by vendor-specific implementations -+ to provide alternative LLDP data sources (e.g., Dell OEM endpoints). -+ -+ Default implementation uses standard Redfish LLDP data from -+ Port.Ethernet.LLDPReceive via Sushy NetworkAdapter/Port resources. -+ -+ :param task: A TaskManager instance -+ :param system: Sushy system object -+ :returns: Dict mapping interface names to parsed LLDP data -+ Format: {'interface_name': {'switch_chassis_id': '..', -+ 'switch_port_id': '..'}} -+ """ -+ parsed_lldp = {} -+ -+ try: -+ # Check if chassis exists -+ if not system.chassis: -+ return parsed_lldp -+ -+ # Process each chassis -+ for chassis in system.chassis: -+ try: -+ # Get NetworkAdapters collection -+ network_adapters = ( -+ chassis.network_adapters.get_members()) -+ except sushy.exceptions.SushyError as ex: -+ LOG.debug('Failed to get network adapters for chassis ' -+ 'on node %(node)s: %(error)s', -+ {'node': task.node.uuid, 'error': ex}) -+ continue -+ -+ # Process each NetworkAdapter -+ for adapter in network_adapters: -+ try: -+ # Get Ports collection using Sushy -+ ports = adapter.ports.get_members() -+ except sushy.exceptions.SushyError as ex: -+ LOG.debug('Failed to get ports for adapter ' -+ 'on node %(node)s: %(error)s', -+ {'node': task.node.uuid, 'error': ex}) -+ continue -+ -+ # Process each Port -+ for port in ports: -+ try: -+ # Check if LLDP data exists using Sushy -+ if (not port.ethernet -+ or not port.ethernet.lldp_receive): -+ continue -+ -+ lldp_receive = port.ethernet.lldp_receive -+ -+ # Convert directly to parsed LLDP format -+ lldp_dict = self._convert_lldp_receive_to_dict( -+ lldp_receive) -+ -+ if not lldp_dict: -+ continue -+ -+ # Use port identity directly as interface name -+ if port.identity: -+ parsed_lldp[port.identity] = lldp_dict -+ -+ except Exception as e: -+ LOG.debug('Failed to process LLDP data for port ' -+ '%(port)s on node %(node)s: %(error)s', -+ {'port': port.identity, -+ 'node': task.node.uuid, 'error': e}) -+ continue -+ -+ except Exception as e: -+ LOG.warning('Failed to collect standard Redfish LLDP data for ' -+ 'node %(node)s: %(error)s', -+ {'node': task.node.uuid, 'error': e}) -+ return parsed_lldp -+ -+ def _convert_lldp_receive_to_dict(self, lldp_receive): -+ """Convert Sushy LLDPReceive object directly to parsed dict format. -+ -+ :param lldp_receive: Sushy LLDPReceiveField object or dict -+ :returns: Dict with parsed LLDP data or None -+ """ -+ lldp_dict = {} -+ -+ # Chassis ID -+ chassis_id = self._get_lldp_value(lldp_receive, 'chassis_id', -+ 'ChassisId') -+ if chassis_id: -+ lldp_dict['switch_chassis_id'] = chassis_id -+ -+ # Port ID -+ port_id = self._get_lldp_value(lldp_receive, 'port_id', 'PortId') -+ if port_id: -+ lldp_dict['switch_port_id'] = port_id -+ -+ # System Name -+ system_name = self._get_lldp_value(lldp_receive, 'system_name', -+ 'SystemName') -+ if system_name: -+ lldp_dict['switch_system_name'] = system_name -+ -+ # System Description -+ system_description = self._get_lldp_value(lldp_receive, -+ 'system_description', -+ 'SystemDescription') -+ if system_description: -+ lldp_dict['switch_system_description'] = system_description -+ -+ # Management VLAN ID -+ vlan_id = self._get_lldp_value(lldp_receive, 'management_vlan_id', -+ 'ManagementVlanId') -+ if vlan_id: -+ lldp_dict['switch_vlan_id'] = vlan_id -+ -+ return lldp_dict if lldp_dict else None -+ -+ def _get_lldp_value(self, lldp_receive, attr_name, json_key): -+ """Get value from LLDP receive, handling both dict and object. -+ -+ :param lldp_receive: LLDP data (Sushy object or dict) -+ :param attr_name: Sushy attribute name -+ :param json_key: JSON property name (required) -+ :returns: The value or None -+ """ -+ # Being defensive to handle both Sushy object and dict -+ if isinstance(lldp_receive, dict): -+ return lldp_receive.get(json_key) -+ else: -+ return getattr(lldp_receive, attr_name, None) -diff --git a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -index e8c3e01d7..ac9a7e795 100644 ---- a/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -+++ b/ironic/tests/unit/drivers/modules/redfish/test_inspect.py -@@ -734,6 +734,283 @@ class RedfishInspectTestCase(db_base.DbTestCase): - self.assertEqual(expected_properties, - task.driver.inspect._get_pxe_port_macs(task)) - -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_inspect_hardware_ignore_missing_pcie_devices( -+ self, mock_get_system, mock_get_enabled_macs): -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ system_mock.pcie_devices = None -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, -+ self.context) -+ self.assertNotIn('pci_devices', inventory['inventory']) -+ -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_inspect_hardware_ignore_empty_pcie_devices( -+ self, mock_get_system, mock_get_enabled_macs): -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ system_mock.pcie_devices.get_members.return_value = [] -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, -+ self.context) -+ self.assertNotIn('pci_devices', inventory['inventory']) -+ -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_inspect_hardware_ignore_pcie_device_without_functions( -+ self, mock_get_system, mock_get_enabled_macs): -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ mock_pcie_device = mock.Mock() -+ mock_pcie_device.pcie_functions = None -+ system_mock.pcie_devices.get_members.return_value = [mock_pcie_device] -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, -+ self.context) -+ self.assertNotIn('pci_devices', inventory['inventory']) -+ -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_inspect_hardware_pcie_partial_data( -+ self, mock_get_system, mock_get_enabled_macs): -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ # Create a PCIe function with partial data, some fields None -+ mock_pcie_function = mock.Mock() -+ mock_pcie_function.device_class = 'NetworkController' -+ mock_pcie_function.device_id = '0x16d7' -+ mock_pcie_function.vendor_id = None -+ mock_pcie_function.subsystem_id = '0x1402' -+ mock_pcie_function.subsystem_vendor_id = None -+ mock_pcie_function.revision_id = '0x01' -+ -+ mock_pcie_device = mock.Mock() -+ mock_pcie_device.pcie_functions.get_members.return_value = [ -+ mock_pcie_function] -+ system_mock.pcie_devices.get_members.return_value = [mock_pcie_device] -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, -+ self.context) -+ expected_pcie_devices = [ -+ {'class': 'NetworkController', -+ 'product_id': '0x16d7', -+ 'subsystem_id': '0x1402', -+ 'revision': '0x01'} -+ ] -+ self.assertEqual(expected_pcie_devices, -+ inventory['inventory']['pci_devices']) -+ -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_collect_lldp_data_with_complete_lldp( -+ self, mock_get_system, mock_get_enabled_macs): -+ """Test LLDP collection with complete LLDP data""" -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ -+ # Mock NetworkAdapters and Ports with LLDP data -+ mock_chassis = mock.Mock() -+ mock_chassis.identity = 'System.Embedded.1' -+ -+ # Mock Port with complete LLDP data -+ mock_lldp = mock.Mock() -+ mock_lldp.chassis_id = 'c4:7e:e0:e4:55:3f' -+ mock_lldp.chassis_id_subtype = mock.Mock(value='MacAddr') -+ mock_lldp.port_id = 'Ethernet1/8' -+ mock_lldp.port_id_subtype = mock.Mock(value='IfName') -+ mock_lldp.system_name = 'switch-01.example.com' -+ mock_lldp.system_description = 'Cisco IOS XE' -+ mock_lldp.system_capabilities = [ -+ mock.Mock(value='Bridge'), -+ mock.Mock(value='Router')] -+ mock_lldp.management_address_ipv4 = '192.168.1.1' -+ mock_lldp.management_address_ipv6 = None -+ mock_lldp.management_address_mac = None -+ mock_lldp.management_vlan_id = 100 -+ -+ mock_ethernet = mock.Mock() -+ mock_ethernet.lldp_receive = mock_lldp -+ mock_ethernet.associated_mac_addresses = ['14:23:F3:F5:3B:A0'] -+ -+ mock_port = mock.Mock() -+ mock_port.identity = 'NIC.Slot.1-1' -+ mock_port.ethernet = mock_ethernet -+ -+ mock_adapter = mock.Mock() -+ mock_adapter.identity = 'NIC.Slot.1' -+ mock_adapter.ports.get_members.return_value = [mock_port] -+ -+ mock_chassis.network_adapters.get_members.return_value = [mock_adapter] -+ system_mock.chassis = [mock_chassis] -+ -+ # Mock ethernet interface for mapping -+ mock_iface = mock.Mock() -+ mock_iface.identity = 'NIC.Slot.1-1-1' -+ mock_iface.mac_address = '14:23:F3:F5:3B:A0' -+ system_mock.ethernet_interfaces.get_members.return_value = [mock_iface] -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, self.context) -+ -+ # Verify parsed_lldp was collected -+ self.assertIn('parsed_lldp', inventory['plugin_data']) -+ parsed_lldp = inventory['plugin_data']['parsed_lldp'] -+ -+ # Should have one interface with LLDP data using Port ID as name -+ self.assertEqual(1, len(parsed_lldp)) -+ self.assertIn('NIC.Slot.1-1', parsed_lldp) -+ -+ # Verify parsed LLDP format -+ lldp_data = parsed_lldp['NIC.Slot.1-1'] -+ self.assertIsInstance(lldp_data, dict) -+ self.assertIn('switch_chassis_id', lldp_data) -+ self.assertIn('switch_port_id', lldp_data) -+ self.assertIn('switch_system_name', lldp_data) -+ self.assertIn('switch_system_description', lldp_data) -+ -+ # Verify expected values -+ self.assertEqual('c4:7e:e0:e4:55:3f', lldp_data['switch_chassis_id']) -+ self.assertEqual('Ethernet1/8', lldp_data['switch_port_id']) -+ self.assertEqual('switch-01.example.com', -+ lldp_data['switch_system_name']) -+ self.assertEqual('Cisco IOS XE', -+ lldp_data['switch_system_description']) -+ -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_collect_lldp_data_empty_lldp_receive( -+ self, mock_get_system, mock_get_enabled_macs): -+ """Test LLDP collection with empty LLDPReceive (Dell scenario)""" -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ -+ mock_chassis = mock.Mock() -+ mock_chassis.identity = 'System.Embedded.1' -+ -+ # Mock Port with None lldp_receive -+ mock_ethernet = mock.Mock() -+ mock_ethernet.lldp_receive = None -+ -+ mock_port = mock.Mock() -+ mock_port.identity = 'NIC.Slot.1-1' -+ mock_port.ethernet = mock_ethernet -+ -+ mock_adapter = mock.Mock() -+ mock_adapter.ports.get_members.return_value = [mock_port] -+ -+ mock_chassis.network_adapters.get_members.return_value = [mock_adapter] -+ system_mock.chassis = [mock_chassis] -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, self.context) -+ -+ # parsed_lldp should not be in plugin_data when empty -+ self.assertNotIn('parsed_lldp', inventory['plugin_data']) -+ -+ @mock.patch.object(redfish_utils, 'get_enabled_macs', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_collect_lldp_data_no_network_adapters( -+ self, mock_get_system, mock_get_enabled_macs): -+ """Test LLDP collection when NetworkAdapters not available""" -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ -+ mock_chassis = mock.Mock() -+ mock_chassis.identity = 'System.Embedded.1' -+ # Raise exception when accessing network_adapters -+ mock_chassis.network_adapters.get_members.side_effect = ( -+ Exception('Not found')) -+ system_mock.chassis = [mock_chassis] -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ task.driver.inspect.inspect_hardware(task) -+ -+ inventory = inspect_utils.get_inspection_data(self.node, self.context) -+ -+ # Should handle gracefully and not include parsed_lldp -+ self.assertNotIn('parsed_lldp', inventory['plugin_data']) -+ -+ def test_convert_lldp_receive_to_dict_complete(self): -+ """Test dict conversion with complete LLDP data""" -+ mock_lldp = mock.Mock() -+ mock_lldp.chassis_id = 'c4:7e:e0:e4:55:3f' -+ mock_lldp.chassis_id_subtype = mock.Mock(value='MacAddr') -+ mock_lldp.port_id = 'Ethernet1/8' -+ mock_lldp.port_id_subtype = mock.Mock(value='IfName') -+ mock_lldp.system_name = 'switch-01' -+ mock_lldp.system_description = 'Cisco IOS' -+ mock_lldp.system_capabilities = [mock.Mock(value='Bridge')] -+ mock_lldp.management_address_ipv4 = '192.168.1.1' -+ mock_lldp.management_address_ipv6 = None -+ mock_lldp.management_address_mac = None -+ mock_lldp.management_vlan_id = 100 -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ lldp_dict = task.driver.inspect._convert_lldp_receive_to_dict( -+ mock_lldp) -+ -+ # Verify dict format -+ self.assertIsInstance(lldp_dict, dict) -+ self.assertIn('switch_chassis_id', lldp_dict) -+ self.assertIn('switch_port_id', lldp_dict) -+ self.assertIn('switch_system_name', lldp_dict) -+ self.assertIn('switch_system_description', lldp_dict) -+ self.assertIn('switch_vlan_id', lldp_dict) -+ -+ # Verify expected values -+ self.assertEqual('c4:7e:e0:e4:55:3f', lldp_dict['switch_chassis_id']) -+ self.assertEqual('Ethernet1/8', lldp_dict['switch_port_id']) -+ self.assertEqual('switch-01', lldp_dict['switch_system_name']) -+ self.assertEqual('Cisco IOS', lldp_dict['switch_system_description']) -+ self.assertEqual(100, lldp_dict['switch_vlan_id']) -+ -+ def test_convert_lldp_receive_to_dict_minimal(self): -+ """Test dict conversion with minimal LLDP data""" -+ mock_lldp = mock.Mock() -+ mock_lldp.chassis_id = 'aa:bb:cc:dd:ee:ff' -+ mock_lldp.chassis_id_subtype = None -+ mock_lldp.port_id = 'port-1' -+ mock_lldp.port_id_subtype = None -+ mock_lldp.system_name = None -+ mock_lldp.system_description = None -+ mock_lldp.system_capabilities = None -+ mock_lldp.management_address_ipv4 = None -+ mock_lldp.management_address_ipv6 = None -+ mock_lldp.management_address_mac = None -+ mock_lldp.management_vlan_id = None -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ lldp_dict = task.driver.inspect._convert_lldp_receive_to_dict( -+ mock_lldp) -+ -+ # Should have Chassis ID and Port ID only -+ self.assertEqual(2, len(lldp_dict)) -+ self.assertIn('switch_chassis_id', lldp_dict) -+ self.assertIn('switch_port_id', lldp_dict) -+ self.assertEqual('aa:bb:cc:dd:ee:ff', lldp_dict['switch_chassis_id']) -+ self.assertEqual('port-1', lldp_dict['switch_port_id']) -+ - - class ContinueInspectionTestCase(db_base.DbTestCase): - def setUp(self): --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0006-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch b/containers/ironic/patches/0006-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch deleted file mode 100644 index 3573bd7b1..000000000 --- a/containers/ironic/patches/0006-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch +++ /dev/null @@ -1,327 +0,0 @@ -From b313441094d5e6484e03c5e35a4a7cf44e877d45 Mon Sep 17 00:00:00 2001 -From: Nidhi Rai -Date: Thu, 11 Dec 2025 19:59:04 +0530 -Subject: [PATCH 6/7] Add LLDP collect for DRAC Redfish inspection - -The implementation collects LLDP data from Dell OEM SwitchConnection endpoints and falls back to standard Redfish LLDP collection when Dell specific data is unavailable. - -Change-Id: If8b1ec059dbeb5a8b29f2b83b1a3e29bfa21bc1b -Signed-off-by: Nidhi Rai ---- - ironic/drivers/modules/drac/inspect.py | 81 +++++++- - .../unit/drivers/modules/drac/test_inspect.py | 188 ++++++++++++++++++ - 2 files changed, 267 insertions(+), 2 deletions(-) - -diff --git a/ironic/drivers/modules/drac/inspect.py b/ironic/drivers/modules/drac/inspect.py -index a880eeadd..001489d3b 100644 ---- a/ironic/drivers/modules/drac/inspect.py -+++ b/ironic/drivers/modules/drac/inspect.py -@@ -14,12 +14,12 @@ - """ - DRAC inspection interface - """ -- - from ironic.common import boot_modes - from ironic.drivers.modules.drac import utils as drac_utils - from ironic.drivers.modules import inspect_utils - from ironic.drivers.modules.redfish import inspect as redfish_inspect - from ironic.drivers.modules.redfish import utils as redfish_utils -+from oslo_log import log - - - _PXE_DEV_ENABLED_INTERFACES = [('PxeDev1EnDis', 'PxeDev1Interface'), -@@ -27,7 +27,7 @@ _PXE_DEV_ENABLED_INTERFACES = [('PxeDev1EnDis', 'PxeDev1Interface'), - ('PxeDev3EnDis', 'PxeDev3Interface'), - ('PxeDev4EnDis', 'PxeDev4Interface')] - _BIOS_ENABLED_VALUE = 'Enabled' -- -+LOG = log.getLogger(__name__) - - class DracRedfishInspect(redfish_inspect.RedfishInspect): - """iDRAC Redfish interface for inspection-related actions.""" -@@ -107,3 +107,80 @@ class DracRedfishInspect(redfish_inspect.RedfishInspect): - pxe_port_macs = [mac for mac in pxe_port_macs_list] - - return pxe_port_macs -+ -+ def _collect_lldp_data(self, task, system): -+ """Collect LLDP data using Dell OEM SwitchConnection endpoints. -+ -+ Dell iDRAC provides LLDP neighbor information through OEM -+ DellSwitchConnection endpoints. We return parsed LLDP data directly. -+ -+ :param task: A TaskManager instance -+ :param system: Sushy system object -+ :returns: Dict mapping interface names to parsed LLDP data -+ """ -+ parsed_lldp = {} -+ -+ try: -+ # Get Dell switch connection data -+ switch_data = self._get_dell_switch_connections(task) -+ -+ # Convert directly to parsed LLDP format -+ for connection in switch_data: -+ fqdd = connection.get('FQDD') -+ switch_mac = connection.get('SwitchConnectionID') -+ switch_port = connection.get('SwitchPortConnectionID') -+ -+ # Skip unconnected interfaces -+ if (not fqdd or not switch_mac or not switch_port -+ or switch_mac == 'No Link' or switch_port == 'No Link'): -+ continue -+ -+ parsed_lldp[fqdd] = { -+ 'switch_chassis_id': switch_mac, -+ 'switch_port_id': switch_port -+ } -+ -+ LOG.debug("Generated parsed LLDP data for %d interfaces", -+ len(parsed_lldp)) -+ -+ except Exception as e: -+ LOG.debug("Dell OEM LLDP collection failed, falling back to " -+ "standard: %s", e) -+ # Fallback to standard Redfish LLDP collection -+ return super(DracRedfishInspect, self)._collect_lldp_data( -+ task, system) -+ -+ return parsed_lldp -+ -+ def _get_dell_switch_connections(self, task): -+ """Fetch Dell switch connection data via OEM. -+ -+ :param task: A TaskManager instance -+ :returns: List of switch connection dictionaries -+ """ -+ system = redfish_utils.get_system(task.node) -+ -+ # Access Sushy's private connection object -+ try: -+ conn = system._conn -+ base_url = conn._url -+ except AttributeError as e: -+ LOG.debug("Failed to access Sushy connection object: %s", e) -+ return [] -+ -+ # Dell OEM endpoint for switch connections -+ # This URL structure is specific to Dell iDRAC Redfish implementation -+ switch_url = (f"{base_url}/redfish/v1/Systems/{system.identity}" -+ "/NetworkPorts/Oem/Dell/DellSwitchConnections") -+ -+ LOG.debug("Fetching Dell switch connections from: %s", switch_url) -+ -+ try: -+ response = conn.get(switch_url) -+ data = response.json() -+ members = data.get('Members', []) -+ LOG.debug("Retrieved %d Dell switch connections", len(members)) -+ return members -+ except Exception as e: -+ LOG.debug("Failed to get Dell switch connections: %s", e) -+ return [] -diff --git a/ironic/tests/unit/drivers/modules/drac/test_inspect.py b/ironic/tests/unit/drivers/modules/drac/test_inspect.py -index 41dfd8b33..4283656ad 100644 ---- a/ironic/tests/unit/drivers/modules/drac/test_inspect.py -+++ b/ironic/tests/unit/drivers/modules/drac/test_inspect.py -@@ -80,6 +80,25 @@ class DracRedfishInspectionTestCase(test_utils.BaseDracTest): - ] - return system_mock - -+ def _setup_lldp_system_mock(self, mock_get_system): -+ """System mock for LLDP tests.""" -+ system_mock = self.init_system_mock(mock_get_system.return_value) -+ system_mock.identity = 'System.Embedded.1' -+ return system_mock -+ -+ def _setup_dell_connection_mock(self, system_mock, url='https://bmc.example.com/redfish/v1'): -+ """Helper to setup Dell connection mock for LLDP tests.""" -+ mock_conn = mock.MagicMock() -+ mock_conn._url = url -+ system_mock._conn = mock_conn -+ return mock_conn -+ -+ def _create_switch_connections_response(self, members): -+ """Create a Mock response for Dell switch connections.""" -+ mock_response = mock.MagicMock() -+ mock_response.json.return_value = {'Members': members} -+ return mock_response -+ - def test_get_properties(self): - expected = redfish_utils.COMMON_PROPERTIES - driver = drac_inspect.DracRedfishInspect() -@@ -158,3 +177,172 @@ class DracRedfishInspectionTestCase(test_utils.BaseDracTest): - shared=True) as task: - return_value = task.driver.inspect._get_mac_address(task) - self.assertEqual(expected_value, return_value) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_collect_lldp_data_successful_dell_oem(self, mock_get_system): -+ """Test successful LLDP data collection from Dell OEM endpoints.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ mock_conn = self._setup_dell_connection_mock(system_mock) -+ -+ # Mock the HTTP response with switch connections -+ members = [ -+ { -+ 'FQDD': 'NIC.Integrated.1-1-1', -+ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:ff', -+ 'SwitchPortConnectionID': 'Ethernet1/0/1' -+ }, -+ { -+ 'FQDD': 'NIC.Integrated.1-1-2', -+ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:gg', -+ 'SwitchPortConnectionID': 'Ethernet1/8' -+ } -+ ] -+ mock_response = self._create_switch_connections_response(members) -+ mock_conn.get.return_value = mock_response -+ -+ expected_lldp = { -+ 'NIC.Integrated.1-1-1': { -+ 'switch_chassis_id': 'aa:bb:cc:dd:ee:ff', -+ 'switch_port_id': 'Ethernet1/0/1' -+ }, -+ 'NIC.Integrated.1-1-2': { -+ 'switch_chassis_id': 'aa:bb:cc:dd:ee:gg', -+ 'switch_port_id': 'Ethernet1/8' -+ } -+ } -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._collect_lldp_data(task, system_mock) -+ self.assertEqual(expected_lldp, result) -+ -+ @mock.patch.object(redfish_inspect.RedfishInspect, '_collect_lldp_data', -+ autospec=True) -+ @mock.patch.object(drac_inspect.DracRedfishInspect, -+ '_get_dell_switch_connections', autospec=True) -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_collect_lldp_data_fallback_to_standard(self, mock_get_system, -+ mock_get_connections, -+ mock_super_collect): -+ """Test fallback to standard Redfish LLDP when Dell OEM fails.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ -+ # Mock _get_dell_switch_connections to raise an exception -+ mock_get_connections.side_effect = Exception("Dell OEM failed") -+ -+ # Mock fallback response -+ mock_super_collect.return_value = { -+ 'NIC.Integrated.1-1-1': { -+ 'switch_chassis_id': 'fallback_chassis', -+ 'switch_port_id': 'fallback_port' -+ } -+ } -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._collect_lldp_data(task, system_mock) -+ # Should return the fallback data -+ mock_super_collect.assert_called_once_with( -+ task.driver.inspect, task, system_mock) -+ self.assertEqual(mock_super_collect.return_value, result) -+ -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_collect_lldp_data_filters_no_link(self, mock_get_system): -+ """Test that 'No Link' connections are filtered out.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ mock_conn = self._setup_dell_connection_mock(system_mock) -+ -+ # Mock the HTTP response with mixed valid/invalid connections -+ members = [ -+ { -+ 'FQDD': 'NIC.Integrated.1-1-1', -+ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:ff', -+ 'SwitchPortConnectionID': 'Ethernet1/8' -+ }, -+ { -+ 'FQDD': 'NIC.Integrated.1-1-2', -+ 'SwitchConnectionID': 'No Link', -+ 'SwitchPortConnectionID': 'No Link' -+ }, -+ { -+ 'FQDD': None, -+ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:gg', -+ 'SwitchPortConnectionID': 'Ethernet1/8' -+ } -+ ] -+ mock_response = self._create_switch_connections_response(members) -+ mock_conn.get.return_value = mock_response -+ -+ expected_lldp = { -+ 'NIC.Integrated.1-1-1': { -+ 'switch_chassis_id': 'aa:bb:cc:dd:ee:ff', -+ 'switch_port_id': 'Ethernet1/8' -+ } -+ } -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._collect_lldp_data(task, system_mock) -+ self.assertEqual(expected_lldp, result) -+ -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_get_dell_switch_connections_success(self, mock_get_system): -+ """Test successful retrieval of Dell switch connections.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ mock_conn = self._setup_dell_connection_mock(system_mock) -+ -+ expected_members = [ -+ {'FQDD': 'NIC.Integrated.1-1-1', -+ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:ff'}, -+ {'FQDD': 'NIC.Integrated.1-1-2', -+ 'SwitchConnectionID': 'aa:bb:cc:dd:ee:gg'} -+ ] -+ mock_response = self._create_switch_connections_response( -+ expected_members) -+ mock_conn.get.return_value = mock_response -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._get_dell_switch_connections(task) -+ self.assertEqual(expected_members, result) -+ -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_get_dell_switch_connections_attr_error(self, mock_get_system): -+ """Test AttributeError when accessing private attributes.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ -+ # Mock missing _conn attribute -+ delattr(system_mock, '_conn') -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._get_dell_switch_connections(task) -+ self.assertEqual([], result) -+ -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_get_dell_switch_connections_conn_error(self, mock_get_system): -+ """Test handling of connection errors during HTTP request.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ mock_conn = self._setup_dell_connection_mock(system_mock) -+ -+ # Mock connection failure -+ mock_conn.get.side_effect = Exception("HTTP connection failed") -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._get_dell_switch_connections(task) -+ self.assertEqual([], result) -+ -+ @mock.patch.object(redfish_utils, 'get_system', autospec=True) -+ def test_get_dell_switch_connections_empty_response(self, mock_get_system): -+ """Test handling of empty response from Dell OEM endpoint.""" -+ system_mock = self._setup_lldp_system_mock(mock_get_system) -+ mock_conn = self._setup_dell_connection_mock(system_mock) -+ -+ # Mock empty response -+ mock_response = self._create_switch_connections_response([]) -+ mock_conn.get.return_value = mock_response -+ -+ with task_manager.acquire(self.context, self.node.uuid, -+ shared=True) as task: -+ result = task.driver.inspect._get_dell_switch_connections(task) -+ self.assertEqual([], result) --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/0007-Add-a-kubernetes-provider-for-console-container.patch b/containers/ironic/patches/0007-Add-a-kubernetes-provider-for-console-container.patch deleted file mode 100644 index 087d4e070..000000000 --- a/containers/ironic/patches/0007-Add-a-kubernetes-provider-for-console-container.patch +++ /dev/null @@ -1,1200 +0,0 @@ -From 3856f2183a4e568eb4f05aa561c8626eb80241c0 Mon Sep 17 00:00:00 2001 -From: Steve Baker -Date: Fri, 3 Oct 2025 11:16:53 +1300 -Subject: [PATCH 7/7] Add a kubernetes provider for console container - -A new ``ironic.console.container`` provider is added called -``kubernetes`` which allows Ironic conductor to manage console -containers as Kubernetes pods. The kubernetes resources are defined in -the template file configured by ``[vnc]kubernetes_container_template`` -and the default template creates one secret to store the app info, and -one pod to run the console container. - -It is expected that Ironic conductor is deployed inside the kubernetes -cluster. The associated service account will need roles and bindings -which allow it to manage the required resources (with the default -template this will be secrets and pods). - -This provider holds the assumption that ironic-novnc will be deployed in -the same kubernetes cluster, and so can connect to the VNC servers via -the pod's ``status.hostIP``. - -Assisted-By: gemini -Change-Id: Ib91f7d7c15be51d68ebf886e44efaf191a14437b -Signed-off-by: Steve Baker ---- - doc/source/install/graphical-console.rst | 67 +- - ironic/conf/vnc.py | 23 +- - .../ironic-console-pod.yaml.template | 45 ++ - ironic/console/container/kubernetes.py | 307 +++++++ - .../container/test_console_container.py | 764 +++++++++++++++++- - .../notes/console-k8s-b4aee1bb1d3d0a65.yaml | 18 + - setup.cfg | 1 + - 7 files changed, 1216 insertions(+), 9 deletions(-) - create mode 100644 ironic/console/container/ironic-console-pod.yaml.template - create mode 100644 ironic/console/container/kubernetes.py - create mode 100644 releasenotes/notes/console-k8s-b4aee1bb1d3d0a65.yaml - -diff --git a/ironic/console/container/ironic-console-pod.yaml.template b/ironic/console/container/ironic-console-pod.yaml.template -new file mode 100644 -index 000000000..05770bcf6 ---- /dev/null -+++ b/ironic/console/container/ironic-console-pod.yaml.template -@@ -0,0 +1,45 @@ -+apiVersion: v1 -+kind: Secret -+metadata: -+ name: "ironic-console-{{ uuid }}" -+ namespace: openstack -+ labels: -+ app: ironic -+ component: ironic-console -+ conductor: "{{ conductor }}" -+stringData: -+ app-info: '{{ app_info }}' -+--- -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: "ironic-console-{{ uuid }}" -+ namespace: openstack -+ labels: -+ app: ironic -+ component: ironic-console -+ conductor: "{{ conductor }}" -+spec: -+ containers: -+ - name: x11vnc -+ image: "{{ image }}" -+ imagePullPolicy: Always -+ ports: -+ - containerPort: 5900 -+ resources: -+ requests: -+ cpu: 250m -+ memory: 256Mi -+ limits: -+ cpu: 500m -+ memory: 1024Mi -+ env: -+ - name: APP -+ value: "{{ app }}" -+ - name: READ_ONLY -+ value: "{{ read_only }}" -+ - name: APP_INFO -+ valueFrom: -+ secretKeyRef: -+ name: "ironic-console-{{ uuid }}" -+ key: app-info -\ No newline at end of file -diff --git a/ironic/console/container/kubernetes.py b/ironic/console/container/kubernetes.py -new file mode 100644 -index 000000000..35dfab261 ---- /dev/null -+++ b/ironic/console/container/kubernetes.py -@@ -0,0 +1,307 @@ -+# -+# 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. -+ -+""" -+Kubernetes pod console container provider. -+""" -+import json -+import re -+import time -+import yaml -+ -+from oslo_concurrency import processutils -+from oslo_log import log as logging -+ -+from ironic.common import exception -+from ironic.common import utils -+from ironic.conf import CONF -+from ironic.console.container import base -+ -+LOG = logging.getLogger(__name__) -+ -+# How often to check pod status -+POD_READY_POLL_INTERVAL = 2 -+ -+ -+class KubernetesConsoleContainer(base.BaseConsoleContainer): -+ """Console container provider which uses kubernetes pods.""" -+ -+ def __init__(self): -+ # confirm kubectl is available -+ try: -+ utils.execute("kubectl", "version") -+ except processutils.ProcessExecutionError as e: -+ LOG.exception( -+ "kubectl not available, " "this provider cannot be used." -+ ) -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", reason=e -+ ) -+ if not CONF.vnc.console_image: -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", -+ reason="[vnc]console_image must be set.", -+ ) -+ try: -+ self._render_template() -+ except Exception as e: -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", -+ reason=f"Parsing {CONF.vnc.kubernetes_container_template} " -+ f"failed: {e}", -+ ) -+ -+ def _render_template(self, uuid="", app_name=None, app_info=None): -+ """Render the Kubernetes manifest template. -+ -+ :param uuid: Unique identifier for the node. -+ :param app_name: Name of the application to run in the container. -+ :param app_info: Dictionary of application-specific information. -+ :returns: A string containing the rendered Kubernetes YAML manifest. -+ """ -+ -+ # TODO(stevebaker) Support bind-mounting certificate files to -+ # handle verified BMC certificates -+ -+ if not uuid: -+ uuid = "" -+ if not app_name: -+ app_name = "fake" -+ if not app_info: -+ app_info = {} -+ -+ params = { -+ "uuid": uuid, -+ "image": CONF.vnc.console_image, -+ "app": app_name, -+ "app_info": json.dumps(app_info).strip(), -+ "read_only": CONF.vnc.read_only, -+ "conductor": CONF.host, -+ } -+ return utils.render_template( -+ CONF.vnc.kubernetes_container_template, params=params -+ ) -+ -+ def _apply(self, manifest): -+ try: -+ utils.execute( -+ "kubectl", "apply", "-f", "-", process_input=manifest -+ ) -+ except processutils.ProcessExecutionError as e: -+ LOG.exception("Problem calling kubectl apply") -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", reason=e -+ ) -+ -+ def _delete( -+ self, resource_type, namespace, resource_name=None, selector=None -+ ): -+ args = [ -+ "kubectl", -+ "delete", -+ "-n", -+ namespace, -+ resource_type, -+ "--ignore-not-found=true", -+ ] -+ if resource_name: -+ args.append(resource_name) -+ elif selector: -+ args.append("-l") -+ args.append(selector) -+ else: -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", -+ reason="Delete must be called with either a resource name " -+ "or selector.", -+ ) -+ try: -+ utils.execute(*args) -+ except processutils.ProcessExecutionError as e: -+ LOG.exception("Problem calling kubectl delete") -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", reason=e -+ ) -+ -+ def _get_pod_node_ip(self, pod_name, namespace): -+ try: -+ out, _ = utils.execute( -+ "kubectl", -+ "get", -+ "pod", -+ pod_name, -+ "-n", -+ namespace, -+ "-o", -+ "jsonpath={.status.podIP}", -+ ) -+ return out.strip() -+ except processutils.ProcessExecutionError as e: -+ LOG.exception("Problem getting pod host IP for %s", pod_name) -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", reason=e -+ ) -+ -+ def _wait_for_pod_ready(self, pod_name, namespace): -+ end_time = time.time() + CONF.vnc.kubernetes_pod_timeout -+ while time.time() < end_time: -+ try: -+ out, _ = utils.execute( -+ "kubectl", -+ "get", -+ "pod", -+ pod_name, -+ "-n", -+ namespace, -+ "-o", -+ "json", -+ ) -+ pod_status = json.loads(out) -+ if ( -+ "status" in pod_status -+ and "conditions" in pod_status["status"] -+ ): -+ for condition in pod_status["status"]["conditions"]: -+ if ( -+ condition["type"] == "Ready" -+ and condition["status"] == "True" -+ ): -+ LOG.debug("Pod %s is ready.", pod_name) -+ return -+ except ( -+ processutils.ProcessExecutionError, -+ json.JSONDecodeError, -+ ) as e: -+ LOG.warning( -+ "Could not get pod status for %s: %s", pod_name, e -+ ) -+ -+ time.sleep(POD_READY_POLL_INTERVAL) -+ -+ msg = ( -+ f"Pod {pod_name} did not become ready in " -+ f"{CONF.vnc.kubernetes_pod_timeout}s" -+ ) -+ -+ raise exception.ConsoleContainerError( -+ provider="kubernetes", reason=msg -+ ) -+ -+ def _get_resources_from_yaml(self, rendered, kind=None): -+ """Extracts Kubernetes resources from a YAML manifest. -+ -+ This method parses a multi-document YAML string and yields each -+ Kubernetes resource (dictionary) found. If `kind` is specified, -+ only resources of that specific kind are yielded. -+ -+ :param rendered: A string containing the rendered Kubernetes YAML -+ manifest. -+ :param kind: Optional string, the 'kind' of Kubernetes resource to -+ filter by (e.g., 'Pod', 'Service'). If None, all -+ resources are yielded. -+ :returns: A generator yielding Kubernetes resource dictionaries. -+ """ -+ # Split the YAML into individual documents -+ documents = re.split(r"^---\s*$", rendered, flags=re.MULTILINE) -+ for doc in documents: -+ if not doc.strip(): -+ continue -+ data = yaml.safe_load(doc) -+ if not data: -+ continue -+ if not kind or data.get("kind") == kind: -+ yield data -+ -+ def start_container(self, task, app_name, app_info): -+ """Start a console container for a node. -+ -+ Any existing running container for this node will be stopped. -+ -+ :param task: A TaskManager instance. -+ :raises: ConsoleContainerError -+ """ -+ node = task.node -+ uuid = node.uuid -+ -+ LOG.debug("Starting console container for node %s", uuid) -+ -+ rendered = self._render_template(uuid, app_name, app_info) -+ self._apply(rendered) -+ -+ pod = list(self._get_resources_from_yaml(rendered, kind="Pod"))[0] -+ pod_name = pod["metadata"]["name"] -+ namespace = pod["metadata"]["namespace"] -+ -+ try: -+ self._wait_for_pod_ready(pod_name, namespace) -+ host_ip = self._get_pod_node_ip(pod_name, namespace) -+ except Exception as e: -+ LOG.error( -+ "Failed to start container for node %s, cleaning up.", uuid -+ ) -+ try: -+ self._stop_container(uuid) -+ except Exception: -+ LOG.exception( -+ "Could not clean up resources for node %s", uuid -+ ) -+ raise e -+ -+ return host_ip, 5900 -+ -+ def _stop_container(self, uuid): -+ rendered = self._render_template(uuid) -+ resources = list(self._get_resources_from_yaml(rendered)) -+ resources.reverse() -+ for resource in resources: -+ kind = resource["kind"] -+ name = resource["metadata"]["name"] -+ namespace = resource["metadata"]["namespace"] -+ self._delete(kind, namespace, resource_name=name) -+ -+ def stop_container(self, task): -+ """Stop a console container for a node. -+ -+ Any existing running container for this node will be stopped. -+ -+ :param task: A TaskManager instance. -+ :raises: ConsoleContainerError -+ """ -+ node = task.node -+ uuid = node.uuid -+ self._stop_container(uuid) -+ -+ def _labels_to_selector(self, labels): -+ selector = [] -+ for key, value in labels.items(): -+ selector.append(f"{key}={value}") -+ return ",".join(selector) -+ -+ def stop_all_containers(self): -+ """Stops all running console containers -+ -+ This is run on conductor startup and graceful shutdown to ensure -+ no console containers are running. -+ :raises: ConsoleContainerError -+ """ -+ LOG.debug("Stopping all console containers") -+ rendered = self._render_template() -+ resources = list(self._get_resources_from_yaml(rendered)) -+ resources.reverse() -+ -+ for resource in resources: -+ kind = resource["kind"] -+ namespace = resource["metadata"]["namespace"] -+ labels = resource["metadata"]["labels"] -+ selector = self._labels_to_selector(labels) -+ self._delete(kind, namespace, selector=selector) -diff --git a/ironic/tests/unit/console/container/test_console_container.py b/ironic/tests/unit/console/container/test_console_container.py -index 64c85870e..62c5c6f31 100644 ---- a/ironic/tests/unit/console/container/test_console_container.py -+++ b/ironic/tests/unit/console/container/test_console_container.py -@@ -11,9 +11,12 @@ - # License for the specific language governing permissions and limitations - # under the License. - -+import json - import os - import tempfile -+import time - from unittest import mock -+import yaml - - from oslo_concurrency import processutils - from oslo_config import cfg -@@ -22,6 +25,7 @@ from ironic.common import console_factory - from ironic.common import exception - from ironic.common import utils - from ironic.console.container import fake -+from ironic.console.container import kubernetes - from ironic.tests import base - - CONF = cfg.CONF -@@ -146,8 +150,8 @@ class TestSystemdConsoleContainer(base.TestCase): - @mock.patch.object(utils, 'execute', autospec=True) - def test__host_port(self, mock_exec): - -- mock_exec.return_value = ('5900/tcp -> 192.0.2.1:33819', None) -- container = self.provider._container_name('1234') -+ mock_exec.return_value = ("5900/tcp -> 192.0.2.1:33819", None) -+ container = self.provider._container_name("1234") - self.assertEqual( - ('192.0.2.1', 33819), - self.provider._host_port(container) -@@ -323,3 +327,759 @@ WantedBy=default.target""", f.read()) - mock_exec.reset_mock() - self.provider.stop_all_containers() - mock_exec.assert_not_called() -+ -+ -+class TestKubernetesConsoleContainer(base.TestCase): -+ -+ def setUp(self): -+ super(TestKubernetesConsoleContainer, self).setUp() -+ _reset_provider("kubernetes") -+ self.addCleanup(_reset_provider, "fake") -+ -+ CONF.set_override("console_image", "test-image", "vnc") -+ -+ # The __init__ of the provider calls _render_template, so we need to -+ # mock it here. -+ with mock.patch.object(utils, "render_template", autospec=True): -+ with mock.patch.object( -+ utils, "execute", autospec=True -+ ) as mock_exec: -+ self.provider = ( -+ console_factory.ConsoleContainerFactory().provider -+ ) -+ mock_exec.assert_has_calls( -+ [ -+ mock.call("kubectl", "version"), -+ ] -+ ) -+ -+ def test__render_template(self): -+ CONF.set_override("read_only", True, group="vnc") -+ -+ uuid = "1234" -+ app_name = "fake-app" -+ app_info = {"foo": "bar"} -+ -+ rendered = self.provider._render_template( -+ uuid=uuid, app_name=app_name, app_info=app_info -+ ) -+ -+ self.assertEqual( -+ """apiVersion: v1 -+kind: Secret -+metadata: -+ name: "ironic-console-1234" -+ namespace: openstack -+ labels: -+ app: ironic -+ component: ironic-console -+ conductor: "fake-mini" -+stringData: -+ app-info: '{"foo": "bar"}' -+--- -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: "ironic-console-1234" -+ namespace: openstack -+ labels: -+ app: ironic -+ component: ironic-console -+ conductor: "fake-mini" -+spec: -+ containers: -+ - name: x11vnc -+ image: "test-image" -+ imagePullPolicy: Always -+ ports: -+ - containerPort: 5900 -+ resources: -+ requests: -+ cpu: 250m -+ memory: 256Mi -+ limits: -+ cpu: 500m -+ memory: 1024Mi -+ env: -+ - name: APP -+ value: "fake-app" -+ - name: READ_ONLY -+ value: "True" -+ - name: APP_INFO -+ valueFrom: -+ secretKeyRef: -+ name: "ironic-console-1234" -+ key: app-info""", -+ rendered, -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__apply(self, mock_exec): -+ manifest = "fake-manifest" -+ self.provider._apply(manifest) -+ -+ mock_exec.assert_called_once_with( -+ "kubectl", "apply", "-f", "-", process_input=manifest -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__apply_failure(self, mock_exec): -+ manifest = "fake-manifest" -+ mock_exec.side_effect = processutils.ProcessExecutionError( -+ stderr="ouch" -+ ) -+ -+ self.assertRaisesRegex( -+ exception.ConsoleContainerError, -+ "ouch", -+ self.provider._apply, -+ manifest, -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__delete_by_name(self, mock_exec): -+ self.provider._delete( -+ "pod", "test-namespace", resource_name="test-pod" -+ ) -+ mock_exec.assert_called_once_with( -+ "kubectl", -+ "delete", -+ "-n", -+ "test-namespace", -+ "pod", -+ "--ignore-not-found=true", -+ "test-pod", -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__delete_by_selector(self, mock_exec): -+ self.provider._delete("pod", "test-namespace", selector="app=ironic") -+ mock_exec.assert_called_once_with( -+ "kubectl", -+ "delete", -+ "-n", -+ "test-namespace", -+ "pod", -+ "--ignore-not-found=true", -+ "-l", -+ "app=ironic", -+ ) -+ -+ def test__delete_no_name_or_selector(self): -+ self.assertRaisesRegex( -+ exception.ConsoleContainerError, -+ "Delete must be called with either a resource name or selector", -+ self.provider._delete, -+ "pod", -+ "test-namespace", -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__delete_failure(self, mock_exec): -+ mock_exec.side_effect = processutils.ProcessExecutionError( -+ stderr="ouch" -+ ) -+ self.assertRaisesRegex( -+ exception.ConsoleContainerError, -+ "ouch", -+ self.provider._delete, -+ "pod", -+ "test-namespace", -+ resource_name="test-pod", -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__get_pod_node_ip(self, mock_exec): -+ mock_exec.return_value = ("192.168.1.100", "") -+ ip = self.provider._get_pod_node_ip("test-pod", "test-namespace") -+ self.assertEqual("192.168.1.100", ip) -+ mock_exec.assert_called_once_with( -+ "kubectl", -+ "get", -+ "pod", -+ "test-pod", -+ "-n", -+ "test-namespace", -+ "-o", -+ "jsonpath={.status.podIP}", -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__get_pod_node_ip_failure(self, mock_exec): -+ mock_exec.side_effect = processutils.ProcessExecutionError( -+ stderr="ouch" -+ ) -+ self.assertRaisesRegex( -+ exception.ConsoleContainerError, -+ "ouch", -+ self.provider._get_pod_node_ip, -+ "test-pod", -+ "test-namespace", -+ ) -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ @mock.patch.object(time, "sleep", autospec=True) -+ def test__wait_for_pod_ready(self, mock_sleep, mock_exec): -+ pod_ready_status = { -+ "status": {"conditions": [{"type": "Ready", "status": "True"}]} -+ } -+ mock_exec.return_value = (json.dumps(pod_ready_status), "") -+ -+ self.provider._wait_for_pod_ready("test-pod", "test-namespace") -+ -+ mock_exec.assert_called_once_with( -+ "kubectl", -+ "get", -+ "pod", -+ "test-pod", -+ "-n", -+ "test-namespace", -+ "-o", -+ "json", -+ ) -+ mock_sleep.assert_not_called() -+ -+ @mock.patch.object(utils, "execute", autospec=True) -+ @mock.patch.object(time, "sleep", autospec=True) -+ @mock.patch.object(time, "time", autospec=True, side_effect=[1, 2, 3, 4]) -+ def test__wait_for_pod_ready_polling( -+ self, mock_time, mock_sleep, mock_exec -+ ): -+ pod_not_ready_status = { -+ "status": {"conditions": [{"type": "Ready", "status": "False"}]} -+ } -+ pod_ready_status = { -+ "status": {"conditions": [{"type": "Ready", "status": "True"}]} -+ } -+ mock_exec.side_effect = [ -+ (json.dumps(pod_not_ready_status), ""), -+ (json.dumps(pod_ready_status), ""), -+ ] -+ -+ self.provider._wait_for_pod_ready("test-pod", "test-namespace") -+ -+ self.assertEqual(2, mock_exec.call_count) -+ mock_sleep.assert_called_once_with(kubernetes.POD_READY_POLL_INTERVAL) -+ -+ @mock.patch.object(time, "time", autospec=True, side_effect=[0, 121]) -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__wait_for_pod_ready_timeout(self, mock_exec, mock_time): -+ pod_not_ready_status = { -+ "status": {"conditions": [{"type": "Ready", "status": "False"}]} -+ } -+ mock_exec.return_value = (json.dumps(pod_not_ready_status), "") -+ -+ self.assertRaisesRegex( -+ exception.ConsoleContainerError, -+ "did not become ready", -+ self.provider._wait_for_pod_ready, -+ "test-pod", -+ "test-namespace", -+ ) -+ -+ @mock.patch.object(time, "time", autospec=True, side_effect=[0, 121]) -+ @mock.patch.object(utils, "execute", autospec=True) -+ def test__wait_for_pod_ready_exec_error(self, mock_exec, mock_time): -+ mock_exec.side_effect = processutils.ProcessExecutionError() -+ self.assertRaisesRegex( -+ exception.ConsoleContainerError, -+ "did not become ready", -+ self.provider._wait_for_pod_ready, -+ "test-pod", -+ "test-namespace", -+ ) -+ -+ def test__get_resources_from_yaml_single_doc_no_kind(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+spec: -+ containers: -+ - name: my-container -+ image: nginx -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml) -+ ) -+ self.assertEqual( -+ [ -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "my-pod"}, -+ "spec": { -+ "containers": [ -+ {"image": "nginx", "name": "my-container"} -+ ] -+ }, -+ } -+ ], -+ resources, -+ ) -+ -+ def test__get_resources_from_yaml_multi_doc_no_kind(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+--- -+apiVersion: v1 -+kind: Service -+metadata: -+ name: my-service -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml) -+ ) -+ self.assertEqual( -+ [ -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "my-pod"}, -+ }, -+ { -+ "apiVersion": "v1", -+ "kind": "Service", -+ "metadata": {"name": "my-service"}, -+ }, -+ ], -+ resources, -+ ) -+ -+ def test__get_resources_from_yaml_single_doc_with_kind_match(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml, kind="Pod") -+ ) -+ self.assertEqual( -+ [ -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "my-pod"}, -+ } -+ ], -+ resources, -+ ) -+ -+ def test__get_resources_from_yaml_single_doc_with_kind_no_match(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml( -+ rendered_yaml, kind="Service" -+ ) -+ ) -+ self.assertEqual(0, len(resources)) -+ -+ def test__get_resources_from_yaml_multi_doc_with_kind_match_some(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+--- -+apiVersion: v1 -+kind: Service -+metadata: -+ name: my-service -+--- -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: another-pod -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml, kind="Pod") -+ ) -+ self.assertEqual( -+ [ -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "my-pod"}, -+ }, -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "another-pod"}, -+ }, -+ ], -+ resources, -+ ) -+ -+ def test__get_resources_from_yaml_multi_doc_with_kind_no_match_all(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+--- -+apiVersion: v1 -+kind: Service -+metadata: -+ name: my-service -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml( -+ rendered_yaml, kind="Deployment" -+ ) -+ ) -+ self.assertEqual(0, len(resources)) -+ -+ def test__get_resources_from_yaml_empty_documents(self): -+ rendered_yaml = """ -+--- -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+--- -+ -+--- -+apiVersion: v1 -+kind: Service -+metadata: -+ name: my-service -+--- -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml) -+ ) -+ self.assertEqual( -+ [ -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "my-pod"}, -+ }, -+ { -+ "apiVersion": "v1", -+ "kind": "Service", -+ "metadata": {"name": "my-service"}, -+ }, -+ ], -+ resources, -+ ) -+ -+ def test__get_resources_from_yaml_invalid_yaml(self): -+ rendered_yaml = """ -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+--- -+ - bad: indent -+ - invalid: yaml -+ -+""" -+ try: -+ list(self.provider._get_resources_from_yaml(rendered_yaml)) -+ raise Exception("Expected YAMLError") -+ except yaml.YAMLError: -+ pass -+ -+ def test__get_resources_from_yaml_document_safe_load_none(self): -+ # This can happen if a document is just whitespace or comments -+ rendered_yaml = """ -+# This is a comment -+--- -+apiVersion: v1 -+kind: Pod -+metadata: -+ name: my-pod -+""" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml) -+ ) -+ self.assertEqual( -+ [ -+ { -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": {"name": "my-pod"}, -+ } -+ ], -+ resources, -+ ) -+ -+ def test__get_resources_from_yaml_empty_string(self): -+ rendered_yaml = "" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml) -+ ) -+ self.assertEqual(0, len(resources)) -+ -+ def test__get_resources_from_yaml_whitespace_string(self): -+ rendered_yaml = " \n\n" -+ resources = list( -+ self.provider._get_resources_from_yaml(rendered_yaml) -+ ) -+ self.assertEqual(0, len(resources)) -+ -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_get_pod_node_ip", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_wait_for_pod_ready", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_get_resources_from_yaml", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, "_apply", autospec=True -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_render_template", -+ autospec=True, -+ ) -+ def test_start_container( -+ self, -+ mock_render, -+ mock_apply, -+ mock_get_resources, -+ mock_wait, -+ mock_get_ip, -+ ): -+ task = mock.Mock(node=mock.Mock(uuid="1234")) -+ app_name = "test-app" -+ app_info = {"foo": "bar"} -+ -+ mock_render.return_value = "fake-manifest" -+ mock_get_resources.return_value = [ -+ { -+ "kind": "Pod", -+ "metadata": { -+ "name": "test-pod", -+ "namespace": "test-namespace", -+ }, -+ } -+ ] -+ mock_get_ip.return_value = "192.168.1.100" -+ -+ host, port = self.provider.start_container(task, app_name, app_info) -+ -+ self.assertEqual(("192.168.1.100", 5900), (host, port)) -+ mock_render.assert_called_once_with( -+ self.provider, "1234", app_name, app_info -+ ) -+ mock_apply.assert_called_once_with(self.provider, "fake-manifest") -+ mock_get_resources.assert_called_once_with( -+ self.provider, "fake-manifest", kind="Pod" -+ ) -+ mock_wait.assert_called_once_with( -+ self.provider, "test-pod", "test-namespace" -+ ) -+ mock_get_ip.assert_called_once_with( -+ self.provider, "test-pod", "test-namespace" -+ ) -+ -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_stop_container", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_get_pod_node_ip", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_wait_for_pod_ready", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_get_resources_from_yaml", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, "_apply", autospec=True -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_render_template", -+ autospec=True, -+ ) -+ def test_start_container_failure( -+ self, -+ mock_render, -+ mock_apply, -+ mock_get_resources, -+ mock_wait, -+ mock_get_ip, -+ mock_stop, -+ ): -+ task = mock.Mock(node=mock.Mock(uuid="1234")) -+ mock_render.return_value = "fake-manifest" -+ mock_get_resources.return_value = [ -+ {"metadata": {"name": "test-pod", "namespace": "test-ns"}} -+ ] -+ mock_wait.side_effect = exception.ConsoleContainerError(reason="boom") -+ -+ self.assertRaises( -+ exception.ConsoleContainerError, -+ self.provider.start_container, -+ task, -+ "app", -+ {}, -+ ) -+ mock_stop.assert_called_once_with(self.provider, "1234") -+ mock_get_ip.assert_not_called() -+ -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_stop_container", -+ autospec=True, -+ ) -+ def test_stop_container(self, mock_stop_container): -+ task = mock.Mock(node=mock.Mock(uuid="1234")) -+ self.provider.stop_container(task) -+ mock_stop_container.assert_called_once_with(self.provider, "1234") -+ -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, "_delete", autospec=True -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_get_resources_from_yaml", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_render_template", -+ autospec=True, -+ ) -+ def test__stop_container( -+ self, mock_render, mock_get_resources, mock_delete -+ ): -+ uuid = "1234" -+ mock_render.return_value = "fake-manifest" -+ mock_get_resources.return_value = [ -+ { -+ "kind": "Secret", -+ "metadata": { -+ "name": "ironic-console-1234", -+ "namespace": "test-namespace", -+ }, -+ }, -+ { -+ "kind": "Pod", -+ "metadata": { -+ "name": "ironic-console-1234", -+ "namespace": "test-namespace", -+ }, -+ }, -+ ] -+ -+ self.provider._stop_container(uuid) -+ -+ mock_render.assert_called_once_with(self.provider, uuid) -+ mock_get_resources.assert_called_once_with( -+ self.provider, "fake-manifest" -+ ) -+ mock_delete.assert_has_calls( -+ [ -+ mock.call( -+ self.provider, -+ "Pod", -+ "test-namespace", -+ resource_name="ironic-console-1234", -+ ), -+ mock.call( -+ self.provider, -+ "Secret", -+ "test-namespace", -+ resource_name="ironic-console-1234", -+ ), -+ ] -+ ) -+ self.assertEqual(2, mock_delete.call_count) -+ -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, "_delete", autospec=True -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_labels_to_selector", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_get_resources_from_yaml", -+ autospec=True, -+ ) -+ @mock.patch.object( -+ kubernetes.KubernetesConsoleContainer, -+ "_render_template", -+ autospec=True, -+ ) -+ def test_stop_all_containers( -+ self, -+ mock_render, -+ mock_get_resources, -+ mock_labels_to_selector, -+ mock_delete, -+ ): -+ mock_render.return_value = "fake-manifest" -+ mock_get_resources.return_value = [ -+ { -+ "kind": "Secret", -+ "metadata": { -+ "namespace": "test-ns", -+ "labels": {"app": "ironic"}, -+ }, -+ }, -+ { -+ "kind": "Pod", -+ "metadata": { -+ "namespace": "test-ns", -+ "labels": {"app": "ironic"}, -+ }, -+ }, -+ ] -+ mock_labels_to_selector.return_value = "app=ironic" -+ -+ self.provider.stop_all_containers() -+ -+ mock_render.assert_called_once_with(self.provider) -+ mock_get_resources.assert_called_once_with( -+ self.provider, "fake-manifest" -+ ) -+ mock_labels_to_selector.assert_has_calls( -+ [ -+ mock.call(self.provider, {"app": "ironic"}), -+ mock.call(self.provider, {"app": "ironic"}), -+ ] -+ ) -+ mock_delete.assert_has_calls( -+ [ -+ mock.call( -+ self.provider, "Pod", "test-ns", selector="app=ironic" -+ ), -+ mock.call( -+ self.provider, "Secret", "test-ns", selector="app=ironic" -+ ), -+ ] -+ ) --- -2.50.1 (Apple Git-155) diff --git a/containers/ironic/patches/series b/containers/ironic/patches/series deleted file mode 100644 index 193d6834d..000000000 --- a/containers/ironic/patches/series +++ /dev/null @@ -1,10 +0,0 @@ -# proliantutils -0001-Solve-IPMI-call-issue-results-in-UTF-8-format-error-.patch -#ironic -0001-pass-along-physical_network-to-neutron-from-the-bare.patch -0002-Add-SKU-field-to-Redfish-inspection.patch -0003-hack-for-scheduling-purposes-ignore-ports-with-categ.patch -0004-feat-skip-invalid-mac-addr-interfaces-in-redfish-ins.patch -0005-Add-Redfish-LLDP-data-collection-support-to-the-Redf.patch -0006-Add-LLDP-collect-for-DRAC-Redfish-inspection.patch -0007-Add-a-kubernetes-provider-for-console-container.patch