Context
app/core/scenario_renderer/manifest.py emits manifest/scenario_vms.json, the
scenario's identity declaration consumed by the deployer-cli, by the scenario lifecycle
scripts, and by the _reserved.json collision ledger. It is schema v2, and it gives
each VM exactly one ip and one bridge:
{ "vm_id": 1100, "vm_name": "admin-wazuh", "ip": "192.168.142.100",
"role": "admin", "bridge": "vmbr142" }
That single-address assumption makes it impossible to describe a router, firewall,
dual-homed jump host or pivot box — a VM with a leg in several segments. Which means it
is impossible to describe any realistic segmented topology (simulated internet / DMZ /
office LANs), where the routing and filtering device is part of the exercise rather than
the Proxmox host. A DMZ with no firewall in front of it is just another subnet.
Why a naive v3 is dangerous
Moving ip and bridge down inside a nics[] array makes both fields null at every
existing reader, and almost all of those readers fail silently:
| Consumer |
Sites |
Failure mode |
range42-playbooks scenarios/_check_reserved.sh checks 4, 5, 7, 8 (:79 :86 :126 :145) |
4 |
"\(.bridge) \(.ip)" collapses to the constant "null null" for every VM — the IP-collision safety net dies without printing anything |
range42 range42-context.sh known_hosts flush (:221, :1124) |
2 |
jq -r '.vms[].ip' → null → ssh-keygen -R null no-ops → stale host keys on every redeploy |
range42-playbooks scenario reset/delete scripts |
57 × jq -r '.vms[].ip' across 16 scenarios |
all null, all silent |
range42-ansible_roles-debug-devkit manifest lookups, incl. find_ips_by_bridge / find_vms_by_bridge |
31 |
select(.bridge == $b) matches zero rows |
Proposal: additive v3
Add nics[], and keep top-level ip / bridge as a mirror of nics[0], the
management NIC:
{ "vm_id": 3000, "vm_name": "fw01",
"ip": "192.168.149.10", "bridge": "vmbr149",
"role": "admin",
"nics": [
{ "index": 0, "bridge": "vmbr149", "ip": "192.168.149.10", "gateway": "192.168.149.1" },
{ "index": 1, "bridge": "vmbr150", "ip": "192.168.150.1" },
{ "index": 2, "bridge": "vmbr151", "ip": "192.168.151.1" }
] }
Every existing consumer keeps reading the scalar and keeps working. New consumers read
nics[]. Cost is one redundant field plus a rule that the two must agree — cheap to
assert as an additional check in _check_reserved.sh, alongside a per-NIC cross-product
collision check.
The alternative (a clean v3 that drops the scalars) requires migrating five repos and
~90 call sites in one go, and strands the only collision-checking safety net the project
has in the meantime.
Scope of this issue (backend side only)
manifest.py — emit nics[] + mirrored scalars; bump "version" to 3.
manifest.py:_validate — _duplicates([(vm.bridge, vm.ip) ...]) is structurally
single-NIC; make the collision check a per-NIC cross-product.
types.py — VmSpec.ip / .bridge / .gateway become a NIC list; nics[0] is the
management NIC by definition.
workspace_templates.py:175 — f" Hostname {vm.ip}" in ssh-config.j2 must
resolve to the management NIC explicitly (ssh-config.j2 allows exactly one Hostname
per Host, so this needs a stated rule, not an accident).
bootstrap.py:28-30 and stage01.py:71 — currently emit the scalar
global_vm_ci_ip / global_vm_net_virtio_bridge / global_vm_ci_ip_gw into stage
files. These follow whatever the vm-bootstrap bundle contract becomes.
tests/core/test_scenario_renderer_manifest.py:42 and :134 pin version == 2 and an
exact key-set; they will fail loudly, which is correct.
Blocked on
This is a cross-repo contract change and must not land unilaterally:
- Agreement from the playbooks owner on additive-v3 vs clean-v3.
- The
vm-bootstrap bundle contract (scalar → NIC list) in range42-playbooks — the
manifest change is downstream of it.
- Multi-NIC support in
range42-ansible_roles-proxmox_controller: vm_create.yaml:38
hardcodes a single net0, vm_clone.yaml:18-24 passes no network params at all, and
cloudinit_set_variables.yaml:33 emits ipconfig0 only — so today a second NIC can
be attached but can never be given an IP. Emitting a schema for VMs nothing can build
would be premature.
Until those land, the renderer should keep emitting v2 and reject a >1-NIC scenario with
a clear error naming the blocker.
Context
app/core/scenario_renderer/manifest.pyemitsmanifest/scenario_vms.json, thescenario's identity declaration consumed by the deployer-cli, by the scenario lifecycle
scripts, and by the
_reserved.jsoncollision ledger. It is schema v2, and it giveseach VM exactly one
ipand onebridge:{ "vm_id": 1100, "vm_name": "admin-wazuh", "ip": "192.168.142.100", "role": "admin", "bridge": "vmbr142" }That single-address assumption makes it impossible to describe a router, firewall,
dual-homed jump host or pivot box — a VM with a leg in several segments. Which means it
is impossible to describe any realistic segmented topology (simulated internet / DMZ /
office LANs), where the routing and filtering device is part of the exercise rather than
the Proxmox host. A DMZ with no firewall in front of it is just another subnet.
Why a naive v3 is dangerous
Moving
ipandbridgedown inside anics[]array makes both fieldsnullat everyexisting reader, and almost all of those readers fail silently:
range42-playbooksscenarios/_check_reserved.shchecks 4, 5, 7, 8 (:79 :86 :126 :145)"\(.bridge) \(.ip)"collapses to the constant"null null"for every VM — the IP-collision safety net dies without printing anythingrange42range42-context.shknown_hosts flush (:221,:1124)jq -r '.vms[].ip'→ null →ssh-keygen -R nullno-ops → stale host keys on every redeployrange42-playbooksscenario reset/delete scriptsjq -r '.vms[].ip'across 16 scenariosrange42-ansible_roles-debug-devkitmanifest lookups, incl.find_ips_by_bridge/find_vms_by_bridgeselect(.bridge == $b)matches zero rowsProposal: additive v3
Add
nics[], and keep top-levelip/bridgeas a mirror ofnics[0], themanagement NIC:
{ "vm_id": 3000, "vm_name": "fw01", "ip": "192.168.149.10", "bridge": "vmbr149", "role": "admin", "nics": [ { "index": 0, "bridge": "vmbr149", "ip": "192.168.149.10", "gateway": "192.168.149.1" }, { "index": 1, "bridge": "vmbr150", "ip": "192.168.150.1" }, { "index": 2, "bridge": "vmbr151", "ip": "192.168.151.1" } ] }Every existing consumer keeps reading the scalar and keeps working. New consumers read
nics[]. Cost is one redundant field plus a rule that the two must agree — cheap toassert as an additional check in
_check_reserved.sh, alongside a per-NIC cross-productcollision check.
The alternative (a clean v3 that drops the scalars) requires migrating five repos and
~90 call sites in one go, and strands the only collision-checking safety net the project
has in the meantime.
Scope of this issue (backend side only)
manifest.py— emitnics[]+ mirrored scalars; bump"version"to 3.manifest.py:_validate—_duplicates([(vm.bridge, vm.ip) ...])is structurallysingle-NIC; make the collision check a per-NIC cross-product.
types.py—VmSpec.ip/.bridge/.gatewaybecome a NIC list;nics[0]is themanagement NIC by definition.
workspace_templates.py:175—f" Hostname {vm.ip}"inssh-config.j2mustresolve to the management NIC explicitly (
ssh-config.j2allows exactly oneHostnameper
Host, so this needs a stated rule, not an accident).bootstrap.py:28-30andstage01.py:71— currently emit the scalarglobal_vm_ci_ip/global_vm_net_virtio_bridge/global_vm_ci_ip_gwinto stagefiles. These follow whatever the
vm-bootstrapbundle contract becomes.tests/core/test_scenario_renderer_manifest.py:42and:134pinversion == 2and anexact key-set; they will fail loudly, which is correct.
Blocked on
This is a cross-repo contract change and must not land unilaterally:
vm-bootstrapbundle contract (scalar → NIC list) inrange42-playbooks— themanifest change is downstream of it.
range42-ansible_roles-proxmox_controller:vm_create.yaml:38hardcodes a single
net0,vm_clone.yaml:18-24passes no network params at all, andcloudinit_set_variables.yaml:33emitsipconfig0only — so today a second NIC canbe attached but can never be given an IP. Emitting a schema for VMs nothing can build
would be premature.
Until those land, the renderer should keep emitting v2 and reject a >1-NIC scenario with
a clear error naming the blocker.