Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/understack-workflows/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
[project.scripts]
bmc-kube-password = "understack_workflows.main.bmc_display_password:main"
bmc-password = "understack_workflows.main.print_bmc_password:main"
enroll-fw = "understack_workflows.main.enroll_fw:main"
enroll-netdev = "understack_workflows.main.enroll_netdev:main"
enroll-server = "understack_workflows.main.enroll_server:main"
netapp-configure-interfaces = "understack_workflows.main.netapp_configure_net:main"
Expand Down
121 changes: 121 additions & 0 deletions python/understack-workflows/tests/test_enroll_fw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from types import SimpleNamespace
from unittest.mock import MagicMock

from understack_workflows.main import enroll_fw

BASE_ARGS = {
"name": "PA1410-026701008071",
"physical_network": "f20-1-network",
"ports": [
{
"label": "ethernet1/19",
"mac": "60:15:2b:33:31:22",
"switch": "n11-22-1.dfw3",
"intf": "Ethernet1/43",
}
],
}


def _patch(mocker, node):
"""Patch the generic engine and the Ironic client; return the fake client."""
mocker.patch.object(enroll_fw.enroll_netdev, "enroll")
fake_ironic = MagicMock()
fake_ironic.node.get.return_value = node
mocker.patch(
"understack_workflows.ironic.client.get_ironic_client",
return_value=fake_ironic,
)
return fake_ironic


def test_enroll_fw_reuses_engine_then_records_metadata(mocker):
node = SimpleNamespace(uuid="node-1", driver_info={}, extra={})
fake_ironic = _patch(mocker, node)

enroll_fw.enroll_fw(
**BASE_ARGS,
management_ip="10.15.149.46",
management_switch="n11-22-1d.dfw3",
management_switch_port="Ethernet1/24",
mate_serial="026701010045",
)

# The generic netdev engine handled node + ports + available.
enroll_fw.enroll_netdev.enroll.assert_called_once()
# Firewall metadata layered on: management -> driver_info, mate -> extra.
fake_ironic.node.update.assert_called_once_with(
"node-1",
[
{
"op": "add",
"path": "/driver_info/management_ip",
"value": "10.15.149.46",
},
{
"op": "add",
"path": "/driver_info/management_switch",
"value": "n11-22-1d.dfw3",
},
{
"op": "add",
"path": "/driver_info/management_switch_port",
"value": "Ethernet1/24",
},
{"op": "add", "path": "/extra/mate_serial", "value": "026701010045"},
],
)


def test_enroll_fw_omits_empty_fields(mocker):
node = SimpleNamespace(uuid="node-1", driver_info={}, extra={})
fake_ironic = _patch(mocker, node)

enroll_fw.enroll_fw(**BASE_ARGS, management_ip="10.15.149.46")

fake_ironic.node.update.assert_called_once_with(
"node-1",
[{"op": "add", "path": "/driver_info/management_ip", "value": "10.15.149.46"}],
)


def test_enroll_fw_is_noop_when_metadata_matches(mocker):
node = SimpleNamespace(
uuid="node-1",
driver_info={"management_ip": "10.15.149.46"},
extra={"mate_serial": "026701010045"},
)
fake_ironic = _patch(mocker, node)

enroll_fw.enroll_fw(
**BASE_ARGS,
management_ip="10.15.149.46",
mate_serial="026701010045",
)

fake_ironic.node.update.assert_not_called()


def test_enroll_fw_updates_only_changed_management_ip(mocker):
node = SimpleNamespace(
uuid="node-1",
driver_info={"management_ip": "192.168.1.1"},
extra={},
)
fake_ironic = _patch(mocker, node)

enroll_fw.enroll_fw(**BASE_ARGS, management_ip="10.15.149.46")

fake_ironic.node.update.assert_called_once_with(
"node-1",
[{"op": "add", "path": "/driver_info/management_ip", "value": "10.15.149.46"}],
)


def test_enroll_fw_records_nothing_when_no_fw_fields(mocker):
node = SimpleNamespace(uuid="node-1", driver_info={}, extra={})
fake_ironic = _patch(mocker, node)

enroll_fw.enroll_fw(**BASE_ARGS)

fake_ironic.node.update.assert_not_called()
176 changes: 176 additions & 0 deletions python/understack-workflows/understack_workflows/main/enroll_fw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import argparse
import logging
import os

from ironicclient.common.utils import args_array_to_patch

from understack_workflows import helpers
from understack_workflows.ironic.client import IronicClient
from understack_workflows.main import enroll_netdev

logger = logging.getLogger(__name__)


def main() -> None:
"""Enroll a firewall: a generic netdev node + firewall-specific metadata.

The node, its ports, and the provision-state lifecycle are handled by the
generic enroll-netdev engine (find-or-create, converge, make available).
This entrypoint then records firewall-specific management details in the
node's driver_info and the HA mate serial in extra.

All firewall fields are optional; omitting them changes nothing.
"""
helpers.setup_logger()
args = argument_parser().parse_args()

enroll_fw(
name=args.name,
physical_network=args.physical_network,
ports=enroll_netdev.parse_ports_arg(args.ports),
external_cmdb_id=args.external_cmdb_id,
resource_class=args.resource_class,
management_ip=args.management_ip,
management_switch=args.management_switch,
management_switch_port=args.management_switch_port,
mate_serial=args.mate_serial,
)


def enroll_fw(
*,
name: str,
physical_network: str,
ports: list[dict],
external_cmdb_id: int | str | None = None,
resource_class: str | None = enroll_netdev.DEFAULT_RESOURCE_CLASS,
management_ip: str = "",
management_switch: str = "",
management_switch_port: str = "",
mate_serial: str = "",
) -> None:
# 1. Reuse the generic netdev engine: node + ports + make available.
enroll_netdev.enroll(
name=name,
physical_network=physical_network,
ports=ports,
external_cmdb_id=external_cmdb_id,
resource_class=resource_class,
)

# 2. Layer firewall-specific metadata on top. driver_info/extra are patchable
# while the node is available (only port connectivity attrs are blocked),
record_firewall_metadata(
name=name,
management_ip=management_ip,
management_switch=management_switch,
management_switch_port=management_switch_port,
mate_serial=mate_serial,
)


def record_firewall_metadata(
*,
name: str,
management_ip: str = "",
management_switch: str = "",
management_switch_port: str = "",
mate_serial: str = "",
) -> None:
"""Patch firewall management/identity fields onto an existing node.

Management access goes in driver_info; the HA mate serial goes in extra.
Only non-empty fields that differ from the node are patched, so re-running
(e.g. to record the real management IP later) converges.
"""
client = IronicClient()
node = client.get_node(name)

driver_info = {
key: value
for key, value in {
"management_ip": management_ip,
"management_switch": management_switch,
"management_switch_port": management_switch_port,
}.items()
if value
}
extra = {"mate_serial": mate_serial} if mate_serial else {}

node_driver_info = getattr(node, "driver_info", None) or {}
node_extra = getattr(node, "extra", None) or {}

updates = []
for key, value in driver_info.items():
if node_driver_info.get(key) != value:
updates.append(f"driver_info/{key}={value}")
for key, value in extra.items():
if node_extra.get(key) != value:
updates.append(f"extra/{key}={value}")

if not updates:
logger.info("[node:%s] Firewall metadata already up to date", node.uuid)
return

logger.info("[node:%s] Recording firewall metadata %s", node.uuid, updates)
client.update_node(node.uuid, args_array_to_patch("add", updates))


def argument_parser():
parser = argparse.ArgumentParser(
prog=os.path.basename(__file__),
description="Enroll a firewall (netdev node + firewall metadata)",
)
parser.add_argument("--name", required=True, help="Ironic node name")
parser.add_argument(
"--physical-network",
required=True,
help="Port physical_network",
)
parser.add_argument(
"--ports",
required=True,
help="JSON array of ports (same format as enroll-netdev)",
)
parser.add_argument(
"--external-cmdb-id",
type=helpers.int_or_str,
required=False,
default="",
help="CMDB ID",
)
parser.add_argument(
"--resource-class",
required=False,
default=enroll_netdev.DEFAULT_RESOURCE_CLASS,
help="Ironic resource class",
)
parser.add_argument(
"--management-ip",
required=False,
default="",
help="Management IP -> driver_info.management_ip",
)
parser.add_argument(
"--management-switch",
required=False,
default="",
help="Management switch name -> driver_info.management_switch",
)
parser.add_argument(
"--management-switch-port",
required=False,
default="",
help="Management switch port -> driver_info.management_switch_port",
)
parser.add_argument(
"--mate-serial",
required=False,
default="",
help="HA mate serial number -> extra.mate_serial",
)
return parser


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions workflows/argo-events/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ resources:
- workflowtemplates/sync-provision-state-to-nautobot.yaml
- workflowtemplates/undersync-switch.yaml
- workflowtemplates/keystone-event-project.yaml
- workflowtemplates/enroll-fw.yaml
- workflowtemplates/enroll-netdev.yaml
- workflowtemplates/enroll-server.yaml
- workflowtemplates/reclean-server.yaml
Expand Down
Loading
Loading