From 0fea49e837661438dfb3a0a6e5280f543245abd8 Mon Sep 17 00:00:00 2001 From: crossat <9965773+crossat@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:58:35 +0000 Subject: [PATCH] Accept applied SDN VNets in network validation validate previously listed every interface on the node, so it accepted names a VM cannot attach to (eth0, bond0, vmbr0.50) and rejected SDN VNets, which provisioning already passes through as the VM bridge. get_networks now requests /nodes/{node}/network?type=any_bridge, the bridges on that node including the SDN VNets applied there. Validation is a single lookup against that list. If the listing fails, validation fails. A VNet that exists in the cluster config but is not applied on the node fails, which is the case the pre-flight exists to catch. When the VNet is in a VLAN zone the host applies the tag, so network.vlan must be left unset or the guest tags a second time. --- CHANGELOG.md | 7 ++++- .../arcane_mage/plugins/modules/validate.py | 2 +- docs/configuration.md | 2 +- src/arcane_mage/provisioner.py | 3 +- src/arcane_mage/proxmox.py | 3 +- tests/test_provisioner.py | 28 ++++++++++++++++++- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a697b..2068564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- `validate` now accepts SDN VNets applied on the target node, via `/nodes/{node}/network?type=any_bridge`. The same call also rejects interfaces a VM cannot attach to (`eth0`, `bond0`, `vmbr0.50`). + ## 3.2.0 ### Added @@ -12,7 +18,6 @@ ### Added - **Console copy devices**: every VM is created with `vga: std,clipboard=vnc` and `serial0: socket`. The node's console offers its configure link to the viewer's clipboard (the noVNC clipboard panel) and writes it to the serial port (the xterm.js console). VNC clipboard needs PVE 8.2, below the 8.4.1 minimum. - ## 3.0.0 ### Breaking Changes diff --git a/ansible_collection/runonflux/arcane_mage/plugins/modules/validate.py b/ansible_collection/runonflux/arcane_mage/plugins/modules/validate.py index 7847e40..b3ac341 100644 --- a/ansible_collection/runonflux/arcane_mage/plugins/modules/validate.py +++ b/ansible_collection/runonflux/arcane_mage/plugins/modules/validate.py @@ -31,7 +31,7 @@ required: true type: str network: - description: Network bridge to validate. + description: Network bridge to validate. A Linux or OVS bridge, or an SDN VNet applied on the node. required: true type: str iso_name: diff --git a/docs/configuration.md b/docs/configuration.md index 0f1c282..a2f2072 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,7 +199,7 @@ VM network configuration. Defaults to DHCP if omitted. |---|---|---|---|---| | `ip_allocation` | string | no | `dhcp` | `dhcp` or `static` | | `address_config` | object | if static | | Static IP configuration (see below) | -| `vlan` | integer | no | | VLAN tag (1-4095) | +| `vlan` | integer | no | | VLAN tag (1-4095). Leave unset when the bridge is an SDN VNet in a VLAN zone: the host applies the tag, and a second tag from the guest leaves the VM with no network | | `rate_limit` | integer | no | | Network rate limit. Must be 35, 75, 135, or 250 | **address_config** (required when `ip_allocation: static`): diff --git a/src/arcane_mage/provisioner.py b/src/arcane_mage/provisioner.py index 40e2f5b..34af21d 100644 --- a/src/arcane_mage/provisioner.py +++ b/src/arcane_mage/provisioner.py @@ -236,7 +236,8 @@ async def validate_iso_version(self, node: str, iso_name: str, storage_iso: str) return any(x["content"] == "iso" and x["volid"].endswith(iso_name) for x in res.payload) async def validate_network(self, node: str, network: str) -> bool: - """Validate that the specified network bridge exists on the hypervisor.""" + """Whether `network` is a bridge a VM on `node` can attach to: a Linux or + OVS bridge, or an SDN VNet applied on that node.""" res = await self.api.get_networks(node) if not res: diff --git a/src/arcane_mage/proxmox.py b/src/arcane_mage/proxmox.py index 2de7630..f423bd7 100644 --- a/src/arcane_mage/proxmox.py +++ b/src/arcane_mage/proxmox.py @@ -412,7 +412,8 @@ async def get_hypervisor_nodes(self) -> ApiResponse[list[HypervisorNode]]: return res async def get_networks(self, node: str) -> ApiResponse[list[NetworkIface]]: - endpoint = f"nodes/{node}/network" + """The bridges on `node` a VM can attach to, SDN VNets applied there included.""" + endpoint = f"nodes/{node}/network?type=any_bridge" res = await self._do_get(endpoint, _NETWORK_IFACES) diff --git a/tests/test_provisioner.py b/tests/test_provisioner.py index ee564d9..798fbad 100644 --- a/tests/test_provisioner.py +++ b/tests/test_provisioner.py @@ -5,7 +5,7 @@ import pytest from arcane_mage.provisioner import TIER_CONFIG, Provisioner, is_api_min_version -from arcane_mage.proxmox import ApiFailure, ApiSuccess +from arcane_mage.proxmox import ApiFailure, ApiSuccess, ProxmoxApi class TestIsApiMinVersion: @@ -108,6 +108,32 @@ async def test_validate_network_missing(self, provisioner: Provisioner, mock_api assert result is False + async def test_validate_network_sdn_vnet(self, provisioner: Provisioner, mock_api: AsyncMock): + mock_api.get_networks.return_value = ApiSuccess( + payload=[ + {"iface": "vmbr0", "type": "bridge"}, + {"iface": "flux50", "type": "vnet"}, + ], + ) + + assert await provisioner.validate_network("node1", "flux50") is True + + async def test_validate_network_listing_fails(self, provisioner: Provisioner, mock_api: AsyncMock): + mock_api.get_networks.return_value = ApiFailure(error="Connection Error") + + assert await provisioner.validate_network("node1", "vmbr0") is False + + async def test_get_networks_requests_any_bridge(self): + api = ProxmoxApi.__new__(ProxmoxApi) + api._do_get = AsyncMock(return_value=ApiSuccess(payload=[])) + + await api.get_networks("node1") + + assert api._do_get.await_count == 1 + call = api._do_get.await_args + assert call is not None + assert call.args[0] == "nodes/node1/network?type=any_bridge" + async def test_validate_iso_version_found(self, provisioner: Provisioner, mock_api: AsyncMock): mock_api.get_storage_content.return_value = ApiSuccess( payload=[