diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index 4b3159897..151f4250c 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -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" diff --git a/python/understack-workflows/tests/test_enroll_fw.py b/python/understack-workflows/tests/test_enroll_fw.py new file mode 100644 index 000000000..287832f02 --- /dev/null +++ b/python/understack-workflows/tests/test_enroll_fw.py @@ -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() diff --git a/python/understack-workflows/understack_workflows/main/enroll_fw.py b/python/understack-workflows/understack_workflows/main/enroll_fw.py new file mode 100644 index 000000000..b79f56f4b --- /dev/null +++ b/python/understack-workflows/understack_workflows/main/enroll_fw.py @@ -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() diff --git a/workflows/argo-events/kustomization.yaml b/workflows/argo-events/kustomization.yaml index eaafa6242..0a4ba1e4b 100644 --- a/workflows/argo-events/kustomization.yaml +++ b/workflows/argo-events/kustomization.yaml @@ -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 diff --git a/workflows/argo-events/workflowtemplates/enroll-fw.yaml b/workflows/argo-events/workflowtemplates/enroll-fw.yaml new file mode 100644 index 000000000..9143d730e --- /dev/null +++ b/workflows/argo-events/workflowtemplates/enroll-fw.yaml @@ -0,0 +1,86 @@ +--- +apiVersion: argoproj.io/v1alpha1 +metadata: + name: enroll-fw + annotations: + workflows.argoproj.io/title: Enroll a firewall in Ironic + workflows.argoproj.io/description: | + Defined in `workflows/argo-events/workflowtemplates/enroll-fw.yaml`. + Reuses enroll-netdev for the node/port lifecycle and additionally records + firewall management details in driver_info and the HA mate serial in extra. +kind: WorkflowTemplate +spec: + serviceAccountName: workflow + entrypoint: main + volumes: + - name: baremetal-manage + secret: + secretName: baremetal-manage + items: + - key: clouds.yaml + path: clouds.yaml + arguments: + parameters: + - name: name + - name: physical_network + # JSON array of ports (same format as enroll-netdev). + - name: ports + - name: external_cmdb_id + value: "" + - name: resource_class + value: generic + # Firewall management/identity metadata. management_* land in the node's + # driver_info; mate_serial lands in extra. Omit (leave "") to skip. + - name: management_ip + value: "" + - name: management_switch + value: "" + - name: management_switch_port + value: "" + - name: mate_serial + value: "" + templates: + - name: main + steps: + - - name: enroll-fw + template: enroll-fw + - name: enroll-fw + container: + image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest + command: + - enroll-fw + args: + - --name + - "{{workflow.parameters.name}}" + - --physical-network + - "{{workflow.parameters.physical_network}}" + - --ports + - "{{workflow.parameters.ports}}" + - --external-cmdb-id + - "{{workflow.parameters.external_cmdb_id}}" + - --resource-class + - "{{workflow.parameters.resource_class}}" + - --management-ip + - "{{workflow.parameters.management_ip}}" + - --management-switch + - "{{workflow.parameters.management_switch}}" + - --management-switch-port + - "{{workflow.parameters.management_switch_port}}" + - --mate-serial + - "{{workflow.parameters.mate_serial}}" + volumeMounts: + - mountPath: /etc/openstack + name: baremetal-manage + readOnly: true + envFrom: + - configMapRef: + name: cluster-metadata + env: + - name: WF_NS + value: "{{workflow.namespace}}" + - name: WF_NAME + value: "{{workflow.name}}" + - name: WF_UID + value: "{{workflow.uid}}" + - name: OS_CLOUD + value: understack