Skip to content

Accept SDN VNets in network validation - #15

Open
crossat wants to merge 1 commit into
RunOnFlux:masterfrom
crossat:fix/sdn-vnet-validation
Open

crossat wants to merge 1 commit into
RunOnFlux:masterfrom
crossat:fix/sdn-vnet-validation

Conversation

@crossat

@crossat crossat commented Oct 17, 2026 •

Copy link
Copy Markdown

Summary

arcane-mage validate previously checked /nodes/{node}/network with no filter. That lists every interface on the node, including ones a VM cannot attach to (eth0, bond0, vmbr0.50), and it does not include SDN VNets. Provisioning already accepts a VNet name as the VM bridge, so the pre-flight was both stricter and looser than the path that creates the VM.

This change makes the pre-flight match provisioning.

Validation logic

Provisioner.validate_network does one lookup:

GET /nodes/{node}/network?type=any_bridge

That is the bridges on the target node, including the SDN VNets applied there. It is the same list the Proxmox UI offers in a VM's NIC bridge dropdown. A name passes if it matches an iface in that list.

Behaviour:

  • Classic bridges (vmbr0) pass.
  • Applied SDN VNets (flux50) pass. They appear with "type": "vnet".
  • A VNet that exists in the cluster config but has not been applied on the node fails. /cluster/sdn/vnets is not consulted.
  • Interfaces a VM cannot attach to (eth0, bond0, vmbr0.50) fail. They are not returned by type=any_bridge.
  • If the listing fails, validation fails.

Why this is safe

Provisioning already treated the network name as an opaque bridge string and passed it through to the VM config. The pre-flight now accepts exactly the names the hypervisor will accept on that node, and nothing else. No new permissions are required.

Test coverage

  • Classic bridge present → pass
  • Name missing → fail
  • Applied SDN VNet (type: vnet) → pass
  • Listing fails → fail
  • get_networks requests nodes/{node}/network?type=any_bridge

Docs

docs/configuration.md notes that when the VNet is in a VLAN zone the tag is applied by the host, so network.vlan must be left unset.

Checklist

  • Rebased onto current master (typed responses, 3.0.0)
  • Changelog note under Unreleased
  • Docs and Ansible module description updated

@crossat
crossat force-pushed the fix/sdn-vnet-validation branch from 00a2d00 to f625af3 Compare October 22, 2026 23:16
@crossat

crossat commented Oct 22, 2026

Copy link
Copy Markdown
Author

Rebased onto current master and resolved the conflict in src/arcane_mage/__main__.py (kept upstream formatting, updated the check message to “Network”).

The change is ready for review:

  • Pre-flight now matches provisioning: classic bridges first, then SDN VNets
  • Classic bridges unchanged; missing SDN permission fails closed
  • Tests cover success, missing, permission-denied, and classic VLAN paths
  • Changelog, docs, and Ansible module description updated

Happy to adjust anything you want changed.

@crossat

crossat commented Oct 22, 2026

Copy link
Copy Markdown
Author

@MorningLightMountain713 or @TheTrunk ready for review when you have a moment.

Rebased onto current master and resolved the conflict in src/arcane_mage/__main__.py. Pre-flight now matches provisioning (classic bridges first, then SDN VNets), missing SDN permission fails closed, and tests cover the paths. Changelog, docs, and Ansible module description are updated.

@MorningLightMountain713 MorningLightMountain713 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for the thorough tests. Accepting SDN VNets is the right call: a VNet is a Linux bridge on the node, the VM attaches to it exactly as it does to vmbr0, and nothing inside FluxOS cares which one it is. I'd like the check to work a different way before we merge, though.

Check per node, not cluster-wide. /cluster/sdn/vnets returns the cluster's SDN config. A VNet that has been created but not yet applied is listed, and so is a VNet whose zone is restricted to other nodes. In both cases the pre-flight passes and the VM then fails to start, which is the exact failure the pre-flight exists to catch.

GET /nodes/{node}/network?type=any_bridge returns the bridges on that node including the SDN VNets applied there. It's the same list the Proxmox UI offers in a VM's NIC bridge dropdown. That gives one call, scoped to the target node, reflecting applied state, and get_sdn_vnets goes away. Since you have an SDN setup, could you confirm against it that an applied VNet shows up there with "type": "vnet" and that a pending one doesn't?

As a bonus it tightens the existing check, which currently accepts any interface (eth0, bond0, vmbr0.50), none of which a VM can attach to.

Comments. We keep comments to the contract or constraint the next reader needs, stated as it stands. The reasoning for a change goes in the commit message, not the code. Details inline.

Docs. Please add a line to docs/configuration.md: when the VNet is in a VLAN zone, the tag is applied by the host, so network.vlan in the fluxnode config must be left unset. Otherwise the guest tags a second time and gets no network.

Comment thread src/arcane_mage/provisioner.py Outdated
Comment on lines +244 to +258
"""Validate that the network is a classic bridge or an SDN VNet.

if not res:
return False
Classic bridges come from /nodes/{node}/network. SDN VNets do not
appear there; they are listed by /cluster/sdn/vnets. Provisioning
already accepts either name as the VM bridge, so the pre-flight
check must accept both. A token without SDN access fails closed.
"""
res = await self.api.get_networks(node)

network_exists = next(filter(lambda x: x.get("iface") == network, res.payload), None)
if res and res.payload and any(x.get("iface") == network for x in res.payload):
return True

return bool(network_exists)
# SDN VNets are not returned by /nodes/{node}/network.
vnets = await self.api.get_sdn_vnets()
return bool(vnets and vnets.payload and any(v.get("vnet") == network for v in vnets.payload))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With type=any_bridge this collapses to a single lookup. Something like:

    async def validate_network(self, node: str, network: str) -> bool:
        """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)

        return bool(res and res.payload and any(x.get("iface") == network for x in res.payload))

On the comments as written:

  • "Provisioning already accepts either name as the VM bridge, so the pre-flight check must accept both" is the argument for this PR, which belongs in the commit message. Six months from now the reader needs to know what the function accepts, not why it was changed.
  • "SDN VNets do not appear there; they are listed by /cluster/sdn/vnets" describes the implementation line by line, and with any_bridge it's no longer true.
  • # SDN VNets are not returned by /nodes/{node}/network. repeats the docstring. A comment should earn its place by saying something the code can't, and this one doesn't.

Comment thread src/arcane_mage/proxmox.py Outdated

return res

async def get_sdn_vnets(self) -> ApiResponse:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be dropped in favour of filtering get_networks, its only caller being validate_network:

    async def get_networks(self, node: str) -> ApiResponse:
        """The bridges on `node` a VM can attach to, SDN VNets applied there included."""
        return await self._do_get(f"nodes/{node}/network?type=any_bridge")

A docstring here should state what the call returns, not what some other endpoint doesn't.

Comment thread tests/test_provisioner.py Outdated

assert result is False

async def test_validate_network_classic_unavailable_sdn_ok(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a single lookup this case goes away: if the node's network listing fails, validation fails. That's the behaviour we want, because we can't vouch for a bridge we couldn't list.

Comment thread tests/test_provisioner.py Outdated

assert result is False

async def test_validate_network_vlan_iface_not_treated_as_vnet(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vmbr0.50 is a VLAN interface, not a bridge, so a VM can't attach to it and this should fail validation. With type=any_bridge the API won't return it. I'd swap this for tests that the request carries type=any_bridge, and that an entry with "type": "vnet" passes the same way a "type": "bridge" one does.

The # A classic VLAN interface is a bridge name, not an SDN VNet. comment goes with it.

@MorningLightMountain713

Copy link
Copy Markdown
Collaborator

Heads-up: master moved under this PR, so it will need a rebase onto master (3.0.0).

What changed, briefly:

  • Responses are typed. ProxmoxApi methods now return ApiSuccess[T] | ApiFailure. After if not res:, res.payload has the endpoint's type; for get_networks that's list[NetworkIface]. Each answer is validated when it arrives, so res.payload is never None on success.
  • Tests build responses directly: ApiSuccess(payload=[...]) for a success, ApiFailure(status=403, error="...") for a failure. There's no ApiResponse(...) constructor any more.
  • ty is part of the checks. See CONTRIBUTING.md: ruff check, ruff format, ty check and pytest all need to pass.

For this PR, with the type=any_bridge change from my review, validate_network reduces to:

res = await self.api.get_networks(node)
return bool(res) and any(x["iface"] == network for x in res.payload)

@crossat
crossat force-pushed the fix/sdn-vnet-validation branch from 836f9ee to 8010970 Compare October 23, 2026 07:58
@crossat

crossat commented Oct 23, 2026 •

Copy link
Copy Markdown
Author

Rebased onto current master (typed responses, 3.0.0). The three commits are now one.

Confirmed on my cluster. GET /nodes/<node>/network?type=any_bridge returns applied VNets with "type": "vnet" (flux50) alongside classic bridges with "type": "bridge" (vmbr0, vmbr1). VLAN interfaces such as vmbr0.50 do not appear.

The implementation matches the review:

  • get_networks requests /nodes/{node}/network?type=any_bridge and get_sdn_vnets is gone.
  • Validation is a single lookup; if the listing fails, validation fails.
  • Docstring states the contract. The reasoning is in the commit message.
  • Docs note that a VNet in a VLAN zone applies the tag on the host, so network.vlan must be left unset.
  • Tests cover a classic bridge, an applied VNet (type: vnet), a missing name, a failed listing, and that get_networks requests nodes/{node}/network?type=any_bridge.

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.
@crossat
crossat force-pushed the fix/sdn-vnet-validation branch from de67da0 to 0fea49e Compare October 26, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants