diff --git a/docs/changelog-20260715-d121-d122-stage3-ha-nodelayout.md b/docs/changelog-20260715-d121-d122-stage3-ha-nodelayout.md new file mode 100644 index 0000000..9a08253 --- /dev/null +++ b/docs/changelog-20260715-d121-d122-stage3-ha-nodelayout.md @@ -0,0 +1,116 @@ +# 2026-07-15 -- Stage 3 kickoff: D-121 (HA scale-up + Option C node layout) + D-122 (site shape); phase2 runbook fix-forward + +## Context + +Opened Stage 3 (VR1 DC substrate) on branch `dc-dc-stage3-phase2-dc-substrate`. Walked +`runbooks/dc-dc-phase2-tofu-dc-substrate.md` to surface its blocking decisions before any +`tofu apply`. The walk (plus a bundle.yaml HA review and a whole-host resource validation) +produced two operator rulings and a runbook fix-forward. No cloud mutation this session -- +design decisions, one resource calculation, and doc reconciliation only. + +## What changed + +**D-121 (ADOPTED IN PART) -- VR1 makes HA real + the node layout that carries it.** +`docs/design-decisions.md`. The bundle review found the root fact: VR0 ran its control plane +**single-unit by design** (D-009: hacluster + VIP relations at `num_units: 1`, "decorative... +scale-up is mechanical: 1 -> 3"). The 12 hacluster subordinates and D-036's dual VIPs are already +wired; the units were never added. D-121 adopts the **14-service 1->3 scale-up** (keystone, glance, +neutron-api, nova-cloud-controller, placement, cinder, openstack-dashboard, octavia, barbican, +magnum, designate, ceph-radosgw + rabbitmq + vault; `ceph-rbd-mirror` stays 1 per D-108). Two +realities recorded so the cost is not under-stated: (1) it is a **placement rewrite** (each app's +`to:` must spread across 3 distinct hosts -- co-located units are decorative again; a **Stage-5 +placement-balance rule** was folded in from the openstack0-3 balance analysis: spread each app's 3 +units one-per-control-node so the RAM-heavy trio auto-balances, and distribute the ~42 API containers +evenly ~14/control-node, sized for at 64 GiB); (2) **vault** +is MySQL-backed today (C1 revised D-006; `vault:ha` removed by BUNDLEFIX-002), so its HA path was a +sub-ruling -- **RULED (v-a)** 2026-07-15: keep MySQL-backed, scale to 3, re-add `vault:ha`, gated on +verifying the `1.8/stable` charm does MySQL-backend HA with 3 units (else evaluate Raft via D-068, NOT +etcd). 3-unit vault triples the unseal burden, intersecting OPEN SEC-003. +**Node layout = Option C (operator-ruled)**: 3 control + 2 compute + 3 storage per DC (16 node VMs). +Ruled sizing: control 16 vCPU/64 GiB/150 GiB, compute 12/48/100, storage 8/24/550. `ceph-osd`=3/DC, +`nova-compute`=2/DC, `ceph-mon`/`mysql`/`rabbitmq`/`vault`/`ovn-central`=3 across the control nodes. + +**Whole-host resource validation (Option C).** Measured the actual VR1 host (not the design +estimate): **256 vCPU / 1024 GiB RAM / 10 TiB disk** (`nproc`/`free`/`df`; currently only Office1 +runs -- `voffice1` 16/32/600 + `office1-opnsense` 2/2/11, netbox+tailscale nested inside voffice1). +Modeled Option C across all layers (Office1 as-built + both DCs + edges + containment overhead) at +`scratchpad/optc-calc.py`: **222 vCPU / 790 GiB / 5.4 TiB -> FITS** (RAM binding at 77%; vCPU +overcommits; disk thin-provisioned at 53%). Finding: C is NOT heavier than B -- it is RAM-lighter +(790 vs 838 GiB) because role separation lets storage nodes run lean. Sensitivity: control-node RAM +is the knob (96 GiB -> 982 GiB / 96%); held <= 64 GiB. This corrected D-121's first-pass "C is +heaviest" characterization. Also ran `scripts/dc-dc-ceph-disk-budget.sh` (Option B/C 4+4 and 3+2+3): +PASS with wide margin -- disk is not the binding constraint. + +**D-122 (ADOPTED) -- VR1 site shape.** `docs/design-decisions.md`. Records the operator's Ruling 2: +each site nested in its own containment VM (site-down/recovery + site-to-site drills); dark fiber +DC-DC-Office is East-West ONLY (never an internet path); each site has its own dedicated L3 "ISP" +uplink (build `vr1-dc0-wan` on the office1-wan pattern -- NOT a mesh leg); the DC OPNsense edge +follows the proven Office1 pattern (2048 MiB / 2 vCPU / nano, REST-API config per D-113(a2)); each +site runs its own MAAS controller (may bootstrap from an existing one). Closes the phase2 runbook's +Ruling-1/Ruling-2 STOP-gates. Subsumes the DOCFIX-185 amendment note. +**Edge NIC model ruled (D-122 refinement, operator 2026-07-15): 2-NIC, baremetal-matched.** NIC count +mirrors the Roosevelt hardware -- nodes = 6 NICs (one per plane, measured on VR0 openstack0-3), edge = 2 +(WAN + LAN). The six planes are routed by the FABRIC (OVN/OpenStack on the deliberately isolated-L2 +`dc-planes` segments), so the edge is only the external boundary, not an inter-plane router -- no +`opnsense-edge` module extension. Edge WAN = `vr1-dc0-wan` (`172.30.2.0/24`, D-115); edge LAN = +**provider-public** (`10.12.4.0/22`; external gateway per D-100). This resolves the runbook's earlier +"LAN role / build a site-internal segment" flag: no new segment is needed. Two build prerequisites +remain: build `vr1-dc0-wan` as a tofu module (promote off the virsh `office1-wan` one-off), and register +`172.30.2.0/24` in office1-netbox (D-115-ruled, not yet loaded -- absent from the draft; no import +covered the DC Edge /24s). + +**phase2 runbook fix-forward.** `runbooks/dc-dc-phase2-tofu-dc-substrate.md`: +- Region-qualified to the D-119 namespace -- every `dc1`/`dc2` object renamed to `vr1-dc0`/`vr1-dc1` + (matching `main.tf`); added a naming note + a HAZARD line that any residual bare `dcN` reintroduces + the exact cross-cloud collision D-119 deletes. Revert: `git checkout` the file. +- Steps 4-5 rewritten to the D-113(a2) REST-API edge bootstrap -- dropped the config.xml render + the + config-ISO path (D-112: the Importer cannot fire on a nano image; the module already dropped + `config_iso_path`); edge is now prep-image + post-boot console/SSH/API. WAN_IF/LAN_IF agonizing + removed (measured post-boot). +- D-122 edge shape wired into Step 5: `memory_mib=2048`/`vcpu=2`/nano, no `disk_size_bytes`, + `wan_network_name = vr1-dc0-wan` (dedicated per-site ISP uplink, office1-wan pattern), + `lan_network_name` = site-internal network -- both flagged as genuine build prerequisites. +- D-121 Option C wired into Step 6: 8 `module "vr1_dc0_node_*"` blocks in three role-sizes (3 control + 16 vCPU/65536 MiB/150 GiB, 2 compute 12/49152/100 GiB, 3 storage 8/24576/550 GiB; disk in bytes via + `N * 1024*1024*1024`), with the Stage-5 HA-scale-up context. +- Corrected the `vr1-dc1` gate rationale (top callout + exit GATE + checklist): its supernet IS + assigned (D-115 `10.12.64.0/19`, imported to office1-netbox); it stays out of scope by SEQUENCING, + not a missing literal. No `vr1-dc1` build steps added. +- Sequence block, exit GATE, Step-3 register, and delivery checklist updated to match; open items kept + as flagged STOPs (per-DC MAAS rack controller = Stage-4 gap, honest first-run scope Steps 1-8; the + vault-HA backend sub-ruling; netem params). Revert: `git checkout runbooks/dc-dc-phase2-tofu-dc-substrate.md`. + +**Two gaps surfaced by the fix-forward (LOGGED, not fixed -- they need real values/build work):** +1. `opentofu/main.tf`'s commented `vr1_dc1_planes` block (~L38-50) still says "wait for NetBox to + assign D-101's supernet" -- STALE per D-115. Left in place (this was a doc-only pass); flagged for + the `main.tf` owner. +2. **Edge network prerequisites (LAN side RESOLVED after the operator's fabric-routing ruling).** The + fix-forward first flagged "no site-internal LAN segment for `vr1-dc0`"; the operator then ruled the + planes are **fabric-routed** and the edge is **2-NIC**, so the **LAN = provider-public** (a + `vr1_dc0_planes` output) -- NO new site-internal segment needed. Two REAL prerequisites remain before + Step 5's `tofu apply`: build **`vr1-dc0-wan`** as a tofu module (promote off the virsh `office1-wan` + one-off), and register **`172.30.2.0/24`** in office1-netbox (D-115-ruled, not yet loaded). Recorded + in the runbook, not invented. + +**Ledger + tracker reconciliation.** `docs/session-ledger.md` (next-free D 121->123; a Stage-3 +open-work block with the two rulings, the validation, the fix-forward-in-flight, the stale-gate +correction, and the logged-not-executed calc-promotion follow-up) and +`docs/dc-dc-deployment-workflow.md` (Stage 3 State row updated from "runbook written" to +"IN PROGRESS + two rulings landed"). + +## Tests / verification + +- `bash scripts/repo-lint.sh` -> 0 fail, 1 legacy WARN (the documented D-001..018 non-ASCII + carve-out; new D-121/D-122 text is ASCII). +- `bash scripts/ledger-scan.sh` -> next-free D = 123; D-121/D-122 not flagged PROPOSED/OPEN. +- Resource model reproducible: `python3 scratchpad/optc-calc.py`. + +## Revert + +- D-121 / D-122: delete the two `## D-121:` / `## D-122:` sections from `docs/design-decisions.md` + (they are append-only additions; nothing else references them yet in code). +- Ledger / workflow: revert the `docs/session-ledger.md` next-free line + the Stage-3 block, and the + `docs/dc-dc-deployment-workflow.md` Stage 3 State paragraph, to their prior text. +- Runbook: `git checkout runbooks/dc-dc-phase2-tofu-dc-substrate.md` (see the runbook section for the + per-change reverts). +- No cloud state was touched, so there is nothing to unwind on the host. diff --git a/docs/changelog-20260715-stage3-prereqs-prep.md b/docs/changelog-20260715-stage3-prereqs-prep.md new file mode 100644 index 0000000..df0c9a6 --- /dev/null +++ b/docs/changelog-20260715-stage3-prereqs-prep.md @@ -0,0 +1,88 @@ +# 2026-07-15 -- Stage 3 batch-deploy prep: OpenTofu wiring, DC-edge NetBox importer, HA overlay, D-123 + +## Context + +Operator directed "get as much ready as possible so we can batch deploy with the least +operator interaction." This batch prepares the Stage-3 substrate + Stage-5 HA artifacts so the +live steps reduce to review-plan-and-apply. NO cloud mutation this session -- all authoring + +offline validation; every `tofu apply`, netbox `--commit`, and juju deploy stays operator-gated. +Fanned out across three disjoint areas (opentofu / netbox / overlays); the opentofu agent hit a +transient API stall and its stream was completed in-session by the main loop. + +## What changed + +**D-123 (PROPOSED) -- DC site-down DR model / node-VM placement.** `docs/design-decisions.md`. +Surfaced a real tension: D-122 records the operator's "each site is one VM, `virsh destroy` = +site-down" intent, but the ADOPTED D-103/D-114 seam + the built tooling create the OpenStack node +VMs as **vcloud-level** domains that MAAS merely DISCOVERS (containment VM scoped to MAAS/LXD + +non-stack machines only). Model A (recommended): nodes stay vcloud-level; `vvr1-dc0` is a MAAS/LXD +headend VM (the proven `voffice1` pattern via `site-headend-install.sh`, which already names it a +target); site-down = destroy the `vr1-dc0-*` domain GROUP. Model B: true single-VM containment +(nodes nested), 4-level nesting, heaviest. **Needs a one-line operator ruling.** + +**OpenTofu Stage-3 wiring (`opentofu/`).** +- **New module `modules/site-wan/`** -- a NAT'd per-site simulated-ISP uplink /24 (generalizes the + virsh-created `office1-wan`; operator 2b ruling). MTU 1500 (ISP-uplink domain, NOT jumbo). Schema + CONFIRMED against the installed provider via `tofu providers schema -json` (the method dc-planes + calls for): `forward = { mode = "nat" }`, `ips = [{ address, prefix }]`, `mtu = { size }`, + attribute-style like dc-planes. DHCP omitted (the edge WAN is static, like office1-opnsense .2). + Per-module `versions.tf` (DOCFIX-179). +- **`main.tf` Stage-3 section** (NOT YET APPLIED, gated): `module "vr1_dc0_wan"` (172.30.2.0/24, + D-115); `module "vr1_dc0_opnsense"` (D-122: 2-NIC, `memory_mib=2048`/`vcpu=2`/nano, no + `disk_size_bytes`/`config_iso_path`, LAN = provider-public plane, WAN = the uplink); 8 node VMs via + a single `for_each` over a `locals` map in D-121 Option C sizes (3 control 16/65536/150GiB, 2 + compute 12/49152/100GiB, 3 storage 8/24576/550GiB), six NICs each with **metal-admin first (PXE)**; + netem HELD as a comment (Step 11, runtime SSH target + unruled params; the vr1-dc0<->office1 mesh + bridge is `virbr7`, measured). **HELD pending D-123:** the `vvr1-dc0` headend + `maas-vm-host` (Step + 9) blocks are not wired. +- Fixed the stale `vr1_dc1_planes` comment (supernet IS assigned, D-115; out of scope by sequencing). +- **`tofu validate` -> Success**; `scripts/opentofu-validate.sh` -> 11/11 modules PASS (incl. site-wan). + +**NetBox DC-edge WAN importer (`netbox/dc-edge-wan-import.py` + `tests/dc-edge-wan-import/`).** +Registers the D-115 DC-edge /24s in office1-netbox: `172.30.2.0/24` -> site `vr1-dc0`, +`172.30.3.0/24` -> site `vr1-dc1`, role `edge`, `scope_type=dcim.site` -- scoping CONFIRMED against +`d115-office-carve.py`/`sandbox-fidelity-check.py` (how office1-wan 172.30.1.0/24 is bound), not the +frozen draft (which predates D-115). Stdlib-only, mirrors `d120-compose-bands.py`: WHOLE-PLAN +PREFLIGHT (both sites resolve before any create, so a missing site cannot half-write), DRY-BY-DEFAULT, +`SANDBOX_HOSTS` guard + `--yes-write-upstream` refusal, WAF-safe UA. Creates ONLY the two /24s (role + +container + sites are read-only preconditions). Harness: **58/58 PASS** (19 static + 39 behavioral, +in-process against a fake NetBox). NOT run against a live NetBox (token operator-held). + +**Bundle HA scale-up overlay (`overlays/dc-ha-scaleup.yaml`).** VR1-specific overlay (NOT a bundle.yaml +edit -- VR0 stays single-unit per D-009) implementing D-121: 14 services -> `num_units: 3`, one-per- +control-node placement via a 3-entry `to:` with `{{VR1_DC0_CONTROL_0N}}` tokens (Stage-4-binding +banner). Three correctness items beyond the literal bump, each flagged in-file: (A) rabbitmq +`min-cluster-size: 3` (D-009 amendment -- avoids the D-062-class formation race); (B) `cluster_count: +3` on the 12 haclusters + vault-hacluster (base pins 1 = decorative); (C) **re-declares +`vault-hacluster`** (it is COMMENTED OUT by BUNDLEFIX-002) and re-adds `[vault:ha, vault-hacluster:ha]` +(D-121 v-a keeps vault MySQL-backed at 3). YAML valid; app names cross-checked vs bundle.yaml; +repo-lint clean; `provider-bundle-check.py` green on the base. + +## Tests / verification + +- `cd opentofu && tofu validate` -> Success; `bash scripts/opentofu-validate.sh` -> 11/11 PASS. +- `bash tests/dc-edge-wan-import/run-tests.sh` -> 58/58 PASS. +- `bash scripts/repo-lint.sh` -> 0 fail, 1 legacy WARN. +- `bash scripts/run-tests-all.sh` -> GAUNTLET: ALL GREEN (61 harnesses). + +## What remains operator-gated / to bind later (the residual for a smooth batch) + +1. **D-123 ruling** (Model A/B) -- unblocks wiring the `vvr1-dc0` headend + `maas-vm-host` (Step 9). +2. **Runtime prep before the edge apply:** `scripts/opnsense-prep-image.sh` to produce the nano at the + `base_volume_path`; the netbox `--commit` to register the two /24s (ready command in the importer's + own header; token operator-held). +3. **Stage-4 binding for the HA overlay:** render `{{VR1_DC0_CONTROL_0N}}` to the measured MAAS machine + IDs; the base `machines:` block + the already-3 services (mysql/ovn/ceph-mon) + memcached + + nova-compute(->2)/ceph-osd(->3) re-bind to the Option C nodes; confirm `--overlay ... --dry-run` + (the `to:` list-replace merge is the one unverified overlay mechanism). +4. **D-121 verifies:** vault charm MySQL-backend HA at 3 units (else Raft via D-068, not etcd); + hacluster `cluster_count` semantics. + +## Revert + +- `git rm -r opentofu/modules/site-wan`; revert the `opentofu/main.tf` Stage-3 section + the + `vr1_dc1_planes` comment; `rm .terraform.lock`-nothing (lock unchanged). +- `git rm netbox/dc-edge-wan-import.py && git rm -r tests/dc-edge-wan-import`. +- `git rm overlays/dc-ha-scaleup.yaml`. +- Delete the `## D-123:` section from `docs/design-decisions.md`. +- No cloud state was touched -- nothing to unwind on the host. diff --git a/docs/changelog-20260716-d124-rack-addressing.md b/docs/changelog-20260716-d124-rack-addressing.md new file mode 100644 index 0000000..d878c13 --- /dev/null +++ b/docs/changelog-20260716-d124-rack-addressing.md @@ -0,0 +1,92 @@ +# 2026-07-16 -- D-124 adopted; vr1-dc0 MAAS rack (vvr1-dc0) wired; rack-mgmt NetBox importer + +## Context + +Continuation of the Stage-3 batch prep. The operator ruled the MAAS model (region on Office1 + +rack controller per DC, D-123) and the rack<->region management addressing (D-124 Scheme A: a +transit link on the office1<->dc0 mesh leg) + rack sizing. This lands the `vvr1-dc0` rack wiring +and the NetBox importer for the addressing. NO cloud mutation -- authoring + offline validation +only; the NetBox `--commit`, the `tofu apply`, and the rack install stay operator-gated. + +## What changed + +**D-124 (ADOPTED 2026-07-16).** `docs/design-decisions.md`. The Office1-region<->DC-rack management +overlay. Answers "why not metal-admin?": the rack's node-facing leg IS metal-admin, but D-100 rules +metal-admin DC-LOCAL and a RULED sub-item makes the Office1<->DC mesh the management path -- so the +rack straddles metal-admin (nodes) + the mesh transit (region). **Scheme A** (transit-numbered mesh, +a /30 or /31 point-to-point) + rack sizing **4 vCPU / 8192 MiB / 80 GiB** ruled. The specific transit +CIDR + rack IP are assigned in office1-netbox (apex; operator-held token) via the D-124 importer -- no +literal invented in-repo. + +**`vvr1-dc0` rack wired (`opentofu/`).** +- `main.tf`: `module "vvr1_dc0"` (cloudinit-vm) -- 4/8192/80, base = the noble image, `expose_nested_virt + = false` (rack-only; the region + its LXD host compose, not the rack), two legs + (`network_names = [metal-admin plane, mesh-vr1-dc0-office1]`), static `network_config` on both legs + with IPs from NEW `vr1_dc0_rack_*` variables (NetBox-assigned via tfvars, not invented) + a route to + the Office1 region (`10.10.0.0/22`, as-built D-115) over the transit. The interface-naming trap + (enp1s0/enp2s0 predicted, confirm on first boot) is flagged in-block. +- `variables.tf`: `vr1_dc0_rack_metal_admin_ip`, `vr1_dc0_rack_transit_ip`, `vr1_dc0_rack_transit_prefix`, + `vr1_dc0_rack_transit_peer_ip` (no defaults). +- `modules/mesh-link/outputs.tf`: added a `network_name` output (for the rack's transit-NIC reference). +- **Step-9 `maas-vm-host` DELIBERATELY STILL DEFERRED (DOCFIX-179):** adding the `provider "maas"` block + it needs would force EVERY plan -- including the Steps 1-8 substrate apply -- to demand MAAS creds, + and vr1-dc0's MAAS does not exist until vvr1-dc0 is up. Wire it as its own step once the rack MAAS is + reachable. +- `tofu validate` -> Success; `scripts/opentofu-validate.sh` -> 11/11 modules PASS. + +**`site-headend-install.sh` rack-only mode.** Added `--role ` (default `region+rack`, +100% backward-compatible). `--role rack` enrolls a DC rack controller to Office1's EXISTING MAAS region +(D-123): disable timesyncd -> install the `maas` snap (3.7/stable) -> `maas init rack --maas-url +--secret `. It SKIPS all region+rack work (PostgreSQL, region init, admin/ +login, LXD, the LXD vm-host, compose-DHCP) -- a DC rack runs no DB, composes nothing (the DC nodes are +OpenTofu-created + discovered via the vcloud virsh vm-host on the region), and metal-admin DHCP is a +region-side runbook step. Traps 1-3 (LXD/dnsmasq) do not apply in rack mode; trap 4's spirit (rack +configures NO DHCP) holds. The enroll secret is read from the file at mutate-time and NEVER echoed +(dry-run prints ``; the harness asserts the sentinel never leaks). The +`maas init rack --maas-url --secret` form is CONFIRMED against the MAAS snap docs/discourse (3.x-stable, +so it holds for the 3.7 pin), not assumed. Harness `tests/site-headend-install/` 19 -> 32 checks +(rack arg-contract, secret-non-leak, region-step-absence, backward-compat). Gauntlet stays 62. + +**Rack-mgmt NetBox importer (`netbox/dc-rack-mgmt-import.py` + `tests/dc-rack-mgmt-import/`).** +Registers the D-124 addressing in office1-netbox: the transit prefix (role `transit`, site-scoped +vr1-dc0) + the rack's metal-admin IP. Stdlib-only, mirrors `dc-edge-wan-import.py`: whole-plan preflight +(transit is /30|/31 with host-bits clear + subnet_of the container; rack IP in `10.12.8.0/22` static +band `.2-.49`, not the `.1` gateway; the transit role + container + vr1-dc0 site exist), DRY-BY-DEFAULT, +`SANDBOX_HOSTS` guard. Both literals are REQUIRED inputs (`--transit-cidr`/`--rack-ip`) -- no invented +CIDR. Harness **96/96** (29 static + 67 behavioral). + +**Scoping flags surfaced by the importer (operator-facing, logged NOT resolved):** +1. **The `transit` role must be seeded in office1-netbox** (die-if-absent precondition, like `edge` + was). No transit/management role exists in the frozen draft; `infra` exists but is explicitly SHARED + infra ("ptp transit, device loopbacks, RR loopbacks, anycast"), so D-124's "own role, mirroring the + Edge role" is a NEW dedicated `transit` role to seed. +2. **The transit may need its OWN supernet, not `10.12.0.0/16` (Cloud).** D-124 mirrors the Edge role, + which got a dedicated `172.30.0.0/16`. If office1-netbox assigns the transit from a dedicated + management/transit supernet (NOT subnet_of Cloud), the importer correctly REFUSES at preflight and + the fix is a one-line `CONTAINER` constant update. Decide the transit supernet when assigning. +3. Transit prefix scope = `dcim.site:vr1-dc0` (mirrors the DC-edge /24s); a region scope may be + preferred -- operator's call at assignment. + +## Tests / verification + +- `cd opentofu && tofu validate` -> Success; `bash scripts/opentofu-validate.sh` -> 11/11 PASS. +- `bash tests/dc-rack-mgmt-import/run-tests.sh` -> 96/96 PASS. +- `bash scripts/repo-lint.sh` -> 0 fail, 1 legacy WARN. +- `bash scripts/run-tests-all.sh` -> GAUNTLET: ALL GREEN (62 harnesses). + +## Operator `--commit` (ready; apex token operator-held) + + NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN= \ + python3 netbox/dc-rack-mgmt-import.py \ + --transit-cidr dc0 transit /30|/31> --rack-ip <10.12.8.2-.49> # dry-run; add --commit to write + +(Seed the `transit` role + confirm the transit supernet first -- see scoping flags 1/2. Then fill the +`vr1_dc0_rack_*` tfvars with the assigned values.) + +## Revert + +- `git checkout opentofu/main.tf opentofu/variables.tf opentofu/modules/mesh-link/outputs.tf` + (removes the `vvr1_dc0` block, the rack vars, the mesh-link output). +- `git rm netbox/dc-rack-mgmt-import.py && git rm -r tests/dc-rack-mgmt-import`. +- Delete the `## D-124:` section from `docs/design-decisions.md`. +- No cloud state touched. diff --git a/docs/dc-dc-deployment-workflow.md b/docs/dc-dc-deployment-workflow.md index 25e6971..948e7ee 100644 --- a/docs/dc-dc-deployment-workflow.md +++ b/docs/dc-dc-deployment-workflow.md @@ -156,7 +156,22 @@ | **Reuse vs new** | NEW (no OpenTofu/multi-rack precedent in VR0 DC0). Explicitly tagged shim: node-VM creation has no Roosevelt analog (physical racking + BMC enlistment replaces it there) -- do not treat as reusable production IaC. | | **Authoring status** | **Runbook WRITTEN 2026-07-09: `runbooks/dc-dc-phase2-tofu-dc-substrate.md`.** DC1-first; DC2 explicitly hard-gated (D-101 supernet unassigned) rather than run with placeholders. Walks every still-open decision (node sizing, MAAS zone/pool, `power_address`, config.xml tokens incl. boot-measured `WAN_IF`/`LAN_IF`, netem params) and stops at the corresponding step if unresolved rather than inventing values. States its own exit gate as "CONDITIONALLY MET AT BEST" pending Stage 2, node sizing, a real OPNsense boot measurement, and D-100's netem ruling. NOT YET EXECUTED. The plane/link/pool layer of `opentofu/` (see Stage 1) is reusable here too. `modules/node-vm` (blank disk, PXE-boot), `modules/opnsense-edge`, and `modules/maas-vm-host` (2026-07-09, registers the virsh host with MAAS via the official `canonical/maas` provider -- deliberately NOT `maas_vm_host_machine`, which composes new VMs and would fight `node-vm`) all now exist -- none instantiated (node sizing, OPNsense config.xml content, and a real MAAS zone/pool are all still pending decisions). `modules/netem-link` gives the netem mechanism for this stage's edge gate too. See `opentofu/README.md`. | -**State:** RUNBOOK WRITTEN, NOT YET EXECUTED, hard-dependent on Stage 2 for its MAAS-registration half. +**State:** IN PROGRESS (branch `dc-dc-stage3-phase2-dc-substrate`). RUNBOOK WRITTEN, NOT YET EXECUTED. +**Two blocking rulings landed 2026-07-15** (from a walk of the runbook's decision register): +- **D-121 (ADOPTED IN PART)** -- node layout = **Option C** (3 control + 2 compute + 3 storage per DC, + 16 node VMs), ruled after a whole-host resource validation (256 vCPU / 1024 GiB / 10 TiB measured; + C fits at 222 / 790 GiB / 5.4 TiB, RAM binding). Ruled per-node sizing recorded in D-121. Also + adopts the 14-service HA scale-up (1->3) that this layout carries at Stage 5. OPEN sub-ruling: the + vault-HA backend fork. +- **D-122 (ADOPTED)** -- DC edge/site shape: Office1 pattern edge (2048/2/nano, REST-API config), a + dedicated per-site `vr1-dc0-wan` uplink (dark fiber is East-West only), site nested in its own + containment VM, per-site MAAS. This CLOSES the runbook's Ruling-1 (node sizing) and Ruling-2 (edge + LAN/WAN) STOP-gates. +Fix-forward IN FLIGHT: runbook reconciled to post-D-119 naming + Steps 4-5 rewritten to the D-113(a2) +REST-API bootstrap. Honest first-run scope remains **Steps 1-8**; Step 9 (MAAS registration) still +gates on the per-DC MAAS rack-controller source (a Stage-4 gap -- no OpenTofu module stands one up yet). +The stale `vr1-dc1` "supernet not assigned" gate is corrected (D-115 assigned `10.12.64.0/19`); vr1-dc1 +stays out of scope by sequencing. --- diff --git a/docs/design-decisions.md b/docs/design-decisions.md index a08a471..e4bb2db 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -3256,3 +3256,303 @@ The NetBox-side carve record (Step 6 -- register the `.2-.49`/dynamic/node child ranges + the two service IPs in the sandbox, then feed upstream) is still OWED and rides with C2; it is NOT done here. + +## D-121: VR1 makes HA real -- scale the decorative single-unit control plane to 3, and the per-DC node layout that carries it + +**Status:** ADOPTED IN PART (2026-07-15, operator ruling). **Node layout = Option C** (3 control + +2 compute + 3 storage per DC, 16 node VMs) -- ruled after the whole-host validation proved it FITS +(details below). The **14-service HA scale-up** (1 -> 3) is ADOPTED. **Vault-HA backend sub-ruling: RESOLVED = (v-a)** +(2026-07-15, operator ruling) -- keep vault MySQL-backed (on the already-3-unit `mysql-innodb-cluster`), +scale to 3, re-add the `vault:ha <-> vault-hacluster:ha` relation. GATE: verify the deployed vault charm +(`1.8/stable`) actually does HA leader-election on the MySQL storage backend with 3 units before commit; +if it does NOT, do not fall back to (v-b) etcd -- evaluate Raft integrated storage and route to D-068 +(etcd storage is deprecated upstream; Raft is the better Roosevelt target). Rationale: reuses existing +HA (zero new apps), keeps control-node container density down, does not front-run the unsettled Roosevelt +backend choice (D-068), and adds no NEW SPOF (the whole cloud already depends on mysql). Governs +Stage 3's `modules/node-vm` sizing/count (was blocking phase2 runbook Step 6 -- now UNBLOCKED for +layout) and the Stage 5 per-DC bundle deploy. Raised by the operator's Stage-3 ruling ("this is the +time we add the additional HA nodes missing from the previous deployments"). + +**Ruled node sizing (Option C, validated to fit -- keep control nodes <= 64 GiB per the sensitivity):** +3x control @ 16 vCPU / 64 GiB / 150 GiB; 2x compute @ 12 vCPU / 48 GiB / 100 GiB; 3x storage @ 8 vCPU +/ 24 GiB / 550 GiB (500 OSD + 50 OS). Whole-host allocation across Office1 + both DCs + edges + +containment overhead = 222 vCPU / 790 GiB / 5.4 TiB of the measured 256 / 1024 / 10240 (RAM binding at +77%). `ceph-osd` = 3 units/DC (the storage nodes), `nova-compute` = 2 units/DC (the compute nodes), +`ceph-mon`/`mysql`/`rabbitmq`/`vault`/`ovn-central` = 3 across the 3 control nodes. + +### The problem this fixes + +VR0/testcloud ran its OpenStack control plane **single-unit by design**. Per **D-009** ("Hacluster +modeling at testcloud scale"): the hacluster + VIP relations were included at `num_units: 1` as a +**decorative** pattern -- "a single unit can't form a real HA quorum" -- explicitly so that +"Roosevelt scale-up is mechanical: change `num_units: 1` -> `num_units: 3` and rerun." The bundle +already carries all 12 hacluster subordinates and, per **D-036**, both provider+metal VIPs on every +clustered API app. VR1 (the regional rehearsal) is where that decorative HA is made real -- the +missing units get added. + +### Measured current state (bundle.yaml, machines 8/9/10/11 -- the 4 VR0 nodes) + +Already at quorum (NO change): `mysql-innodb-cluster`=3 (D-062, must deploy at 3), +`ovn-central`=3 (Raft), `ceph-mon`=3 (Paxos). Scale-out (sized to node count): `ceph-osd`=4, +`nova-compute`=3 (on 9/10/11 -- machine 8 is compute-free today). + +**Missing HA -- 14 apps currently single-unit that this decision scales to 3:** + +| App | Now | -> | Mechanism | Governing | +|---|---|---|---|---| +| keystone, glance, neutron-api, nova-cloud-controller, placement, cinder, openstack-dashboard, octavia, barbican, magnum, designate | 1 | 3 | hacluster + dual VIP (already wired) | D-009, D-036 | +| ceph-radosgw | 1 | 3 | hacluster + VIP (wired; NOT in D-009's named 12 but HA-eligible) | -- | +| rabbitmq-server | 1 | 3 | native clustering / quorum queues (no hacluster) | -- | +| vault | 1 | 3 | see the backend sub-question below | D-006 (revised by C1) | + +Left single (NOT scaled): `ceph-rbd-mirror`=1 (**D-108** rules single-unit for VR1), +`glance-simplestreams-sync`=1 (utility), `memcached`=1 (cache; optional 3), `designate-bind`=1 +(DNS backend; optional 2). + +### Two realities the "mechanical 1->3" framing hides (fold into the execution estimate) + +1. **It is a placement rewrite, not a `num_units` tweak.** Every app pins its unit explicitly + (`keystone to: [lxd:8]`, `glance to: [lxd:11]`, ...). Setting `num_units: 3` with a single-entry + `to:` list makes Juju place one unit there and auto-place the other two wherever -- NOT + guaranteed on three DISTINCT hosts, which is the entire HA value. Each of the 14 apps needs its + `to:` expanded to a real 3-machine spread (anti-affinity). This is the actual work. + + **Placement-balance rule for the Stage-5 rewrite (Option C, 3 control nodes).** The 14 services x 3 + units = 42 API LXD containers land on the **3 control nodes** (`vr1-dc0-control-01..03`) -- ~14 per + node, roughly 2x the density of VR0's balanced ~7 containers/node (measured from the openstack0-3 + bundle placement, 27 control containers over 4 nodes). Two rules keep that density balanced: + - **Spread each app's 3 units one-per-control-node** (`to: [lxd:control-01, lxd:control-02, + lxd:control-03]`). 3-unit HA does this naturally, so the RAM-heavy stateful trio + (`mysql-innodb-cluster`, `rabbitmq-server`, `vault`) auto-balances to one replica per control + node -- do NOT stack two of them on the same node (VR0's own placement already avoided this: + rabbitmq on M10, vault on M11, mysql spread 8/9/10). + - **Distribute the ~42 containers EVENLY (~14/control node), not front-loaded onto control-01.** + The whole-host validation sized control nodes at 64 GiB for exactly this ~14-container load; if a + future rebalance wants ~9/node instead, the lever is a 4th control node per DC (NOT more units). + `ceph-osd`(3) sits on the storage nodes and `nova-compute`(2) on the compute nodes -- neither + competes for control-node RAM. This rule governs the Stage-5 bundle deploy; it does not change the + node COUNT ruled above. +2. **Vault HA backend fork -- RESOLVED = (v-a), see Status.** The bundle runs vault **MySQL-backed** + today (`vault:shared-db -> vault-mysql-router -> mysql-innodb-cluster`), and **C1 revised D-006**: + "etcd/easyrsa dropped for testcloud; Raft-vs-etcd is a Roosevelt item," and the + `vault:ha <-> vault-hacluster:ha` relation was **removed (BUNDLEFIX-002)**. So scaling vault is + NOT "just add units". The operator ruled **(v-a)** (charm-verify gated); (v-b) retained below for + the record: + - **(v-a) Keep MySQL-backed, re-add the `vault:ha` relation, scale to 3** -- lowest delta; + leans on the already-3-unit mysql-innodb-cluster. Must be verified against the deployed vault + charm's real multi-unit/ha behavior on a MySQL backend before committing. + - **(v-b) Adopt D-006's etcd(3)+easyrsa(1) HA backend** -- the originally-documented topology, + but reverses C1 and adds two applications. + **Operator sub-ruling needed.** Either way, 3-unit vault **triples the manual-unseal burden**, + which intersects the still-OPEN **SEC-003** (custodian assignment + second-person unseal + rehearsal) that already keeps `d011-06-vault-unseal` MANUAL and gates D-011's full close. + +### The per-DC node layout (the `modules/node-vm` count/sizing this stage must ratify) + +The site-DOWN / recovery drill the operator wants (D-122) has the **whole DC VM** as its fault +domain, so intra-DC hyperconvergence is acceptable; role separation mainly buys single-node-failure +drills, a secondary concern here. + +| Opt | Per-DC layout | nodes/DC | x2 DC | HA semantics | Whole-host cost (MEASURED, all layers) | +|---|---|---|---|---|---| +| A | 3 hyperconverged | 3 | 6 | quorum survives 1 loss; size=3 has ZERO rebuild headroom | lightest | +| **B (rec)** | **4 hyperconverged (= VR0 baseline)** | **4** | **8** | quorum + 4th host = a size=3 re-replication target after a node loss; lowest delta to the proven VR0 placement | 158 vCPU / **838 GiB** / 5.6 TiB (RAM 82%) | +| C | 3 control + 2 compute + 3 storage | 8 | 16 | cleanest HA/DR, Roosevelt-shaped | 222 vCPU / **790 GiB** / 5.4 TiB (RAM 77%) | + +**Whole-host validation (2026-07-15, MEASURED budget 256 vCPU / 1024 GiB / 10 TiB; model at +`scratchpad/optc-calc.py`, all layers: Office1 as-built + both DCs + edges + containment overhead).** +**Both B and C FIT.** The binding resource is **RAM**, not disk (disk ~53-55%, and thin-provisioned) +and not vCPU (overcommits freely in a sim; C's 87% "allocation" is not a real ceiling). Counter to +the first-pass intuition, **C is NOT heavier than B** -- at lean-sim node sizing C uses *less* RAM +(790 vs 838 GiB) because role separation lets the 6 storage nodes run lean (24 GiB) vs B's uniformly +96-GiB hyperconverged nodes. So **C's real cost is operational (16 node VMs vs 8, larger VR0 delta), +not capacity.** SENSITIVITY: the knob is control-node RAM -- C with 96-GiB control nodes hits 982 GiB +(96%, only 42 GiB margin); keep control nodes <= 64 GiB. Per-node RAM sizing dominates the total far +more than the A/B/C structure does. + +**RULED: Option C (2026-07-15).** The whole-host validation (below) removed the capacity objection -- +C fits, and is in fact RAM-lighter than B -- so the decision turned on HA/DR quality: C is +role-separated and Roosevelt-shaped, giving the cleanest site-down AND single-node-failure drills, +which is the point of the VR1 rehearsal. B (4 hyperconverged/DC) was the runner-up on lowest-delta / +operational-simplicity grounds (my initial recommendation); A (3 nodes) was rejected as FEWER than +VR0's 4, reading against the operator's "additional HA nodes." Control nodes are held <= 64 GiB per +the sensitivity finding. B's rationale is retained here for the record: it was the smallest change +from VR0's proven placement, with three distinct hosts for quorum plus a fourth for Ceph rebuild. + +**Disk-budget check (validated 2026-07-15, `scripts/dc-dc-ceph-disk-budget.sh`, Option B 4+4, 0.2 +backup overhead):** PASS across the plausible per-node OSD range -- 300 GiB/node -> 7.18 TiB margin, +500 GiB -> 5.31 TiB, 700 GiB -> 3.43 TiB. **Disk is NOT the binding constraint for B**, so the +node-COUNT decision is robust regardless of final OSD sizing. NB the per-node OSD footprint is an +ASSUMPTION here (the ratified value pends the OSD-sizing decision and should be re-derived from the +measured DC0 footprint, as Stage 1's own margin was); this run proves headroom, not the final number. + +**Per-node sizing (feeds the module):** at full HA a hyperconverged node runs ~13 control LXD +containers + an OSD + (on 3 of 4) nova-compute, atop D-040's 8 GiB reserved-host-memory. Propose +**16 vCPU / 64-96 GiB RAM / OSD-sized disk** per node (lean to 96 GiB -- 64 is tight once D-040 +reserves 8 and the container stack lands). 8 nodes x 16 vCPU = 128 vCPU and x 96 GiB = 768 GiB, both +inside vcloud's 256 vCPU / 1 TiB with headroom for Office1 + the edges. + +### What the operator ruled (2026-07-15) + +1. **Node layout:** RULED **Option C** (3 control + 2 compute + 3 storage per DC), sizing above. +2. **Vault HA backend:** RULED **(v-a)** MySQL-backed + re-add `vault:ha`, charm-verify gated (Status). + The SEC-003 unseal-burden intersection stands (3-unit vault = 3x manual unseal). +3. **Per-node sizing:** RULED (control 16/64/150, compute 12/48/100, storage 8/24/550; Status). + +**Related:** D-009 (decorative HA pattern this executes), D-006 + C1 (vault backend history), +D-036 (dual VIPs, already present), D-062 (mysql-at-3), D-108 (rbd-mirror stays 1), D-040 +(reserved memory), D-104 (single-unit JUJU controller -- unaffected; this is the OpenStack control +plane, not the Juju bootstrap node), SEC-003 (unseal). Does NOT touch DC2/`vr1-dc1` scope. + +## D-122: VR1 site shape -- nested-per-site containment, dark fiber + dedicated per-site L3 ISP, DC edge follows the Office1 pattern + +**Status:** ADOPTED (2026-07-15, operator ruling this session -- answers Stage-3 Ruling 2). Records +the deployment shape so Stage 3's edge/network steps stop re-deriving it. Governs +`modules/opnsense-edge` wiring for each DC and the per-site uplink networks. + +**Decision (operator-ruled):** + +- **Each site is nested inside its own containment VM** (`voffice1` for Office1; the `vvr1-dc0` / + `vvr1-dc1` analogs per DC), so a "physical site down" and its recovery -- plus site-to-site tests + -- are executable as `virsh destroy ` against a single object. This is D-114's model, + now affirmed as the shape for the DCs too, not just Office1. +- **Dark fiber connects DC <-> DC <-> Office** (the `mesh-*` legs) and is **East-West only** -- + replication / site-to-site, **never an internet path** (consistent with D-100 and DOCFIX-185's + transport model). +- **Each site has its OWN dedicated L3 "ISP" uplink, not shared between sites.** Concretely: build a + per-site WAN network on the **`office1-wan` pattern** (a dedicated NAT'd `/24` uplink segment per + site), e.g. `vr1-dc0-wan`, and attach the DC edge's WAN there -- NOT to a mesh leg. +- **The DC OPNsense edge follows the proven Office1 edge pattern:** sizing **2048 MiB / 2 vCPU / + nano image** (carried from the applied `office1_opnsense`, DOCFIX-189), config over the REST API + (D-113(a2)). +- **Edge NIC model = 2-NIC (operator ruling 2026-07-15), matching the baremetal target.** The NIC + count is shaped to the Roosevelt hardware: **nodes get 6 NICs** (one per plane, as measured on VR0 + openstack0-3), but the **edge gets 2** (WAN + one LAN) -- the six planes are **routed by the fabric** + (the `dc-planes` segments are DELIBERATELY isolated L2; OVN/OpenStack own their L3 addressing/routing, + not libvirt and not the edge). So the edge is NOT an inter-plane router; it is only the external + boundary. This keeps `modules/opnsense-edge` at its existing 2-NIC shape -- **no module extension**. + - **WAN** = the dedicated per-site ISP uplink `vr1-dc0-wan` (`172.30.2.0/24`, D-115; register in + office1-netbox -- not yet loaded -- and build as a tofu module, promoting off the virsh-created + `office1-wan` one-off). + - **LAN** = **provider-public** (`10.12.4.0/22`): the edge is the upstream/external gateway the + provider network exits through (FIP/SNAT egress + GUA injection per D-100's br-ex/provider model). + metal-admin and the other four planes reach external via fabric (OVN) routing, not a direct edge + leg; NTP-from-edge (D-107) is served across that fabric routing. +- **Each site runs its own MAAS controller** (as `voffice1` does), and an existing MAAS controller + MAY be used to bootstrap a new site's deployment. + +**Consequence for Stage 3:** the phase2 runbook's Ruling-1/Ruling-2 open items on edge sizing, WAN +uplink, and LAN role are CLOSED by this entry (build `vr1-dc0-wan`, wire the edge like Office1). The +remaining Stage-3 blocker is D-121 (node layout/sizing) plus the per-DC MAAS rack-controller source +(Stage 4 gap -- honest Stage-3 scope is Steps 1-8 until that is stood up). **Related:** D-114 +(containment VM + MAAS-composed LXD VMs), D-100 (per-site edge, dark fiber), D-107 (headend not a +core-service provider), DOCFIX-185 (edge is a real-ISP router, not an egress airgap), D-113(a2) +(edge config over REST). DOCFIX-185's D-100/D-107 amendment note is subsumed here. + +## D-123: VR1 DC site-down DR model -- node-VM placement + the per-DC MAAS headend (resolves a D-122/D-103 tension) + +**Status:** ADOPTED **Model A** (2026-07-15, operator ruling -- read from "Yes, fire off those tasks" +in response to the A/B question; flag if B was intended). Nodes stay vcloud-level; site-down = destroy +the `vr1-dc0-*` domain group; `vvr1-dc0` is a MAAS/LXD headend (the `voffice1`/`site-headend-install.sh` +pattern). **MAAS MODEL RULED (2026-07-15, operator):** ONE MAAS **region on Office1** + a **rack controller per +DC** (`vvr1-dc0` = the vr1-dc0 rack), matching buildout-design line 110. The rack serves PXE/DHCP +locally on metal-admin and PROXIES OS images from the Office1 region (so no direct DC-edge egress is +needed -- that egress worry dissolves under region+rack). `maas-vm-host` registers vcloud's virsh to the +**Office1 region** (which exists) so MAAS discovers the vr1-dc0 node domains. + +**REMAINING (genuine Stage-4 design, NOT invented here) -- the rack<->region management overlay.** +`vvr1-dc0` must reach the Office1 region (10.10.0.20:5240) to enroll, and it may NOT sit on Office1's +site LAN (site isolation, D-122). Per D-100 the management path is the `office1<->dc0` mesh leg -- but +that segment has **no assigned L3** (the mesh legs are isolated L2), and the rack needs static addresses +on both the mesh (management) and metal-admin (it can't DHCP -- the planes have no libvirt DHCP, and the +rack IS the DHCP server there). So concretely standing up `vvr1-dc0` needs a **NetBox-assigned addressing +pass** for (a) the Office1-region<->DC-rack management subnet on the mesh leg and (b) the rack's +metal-admin static IP, plus a rack-controller **sizing** decision. This is a D-101-family addressing +design owned by `dc-dc-phase3`, not a one-line ruling -- the `vvr1-dc0` + `maas-vm-host` blocks stay +HELD until it lands (assigning IPs by guess would violate hard rule 2). Everything ELSE (Steps 1-8 +substrate) is wired and validated. + +**The tension.** D-122 records the operator's shape as *"each site nested in its own VM so we can test +physical site down"* (`virsh destroy ` = site-down). But the ADOPTED seam D-103 + D-114 says +the OpenStack **node VMs are OpenTofu-created and MAAS merely DISCOVERS them** -- and the built tooling +implements exactly that: `modules/node-vm` creates the nodes on the **vcloud** host attached to the +`vr1_dc0_planes`, and `modules/maas-vm-host` registers **vcloud's** virsh to the DC's MAAS so MAAS +discovers those already-existing domains. D-114 scopes the containment VM to the MAAS/LXD headend + the +**non-stack** (LXD-composed) machines only -- NOT the OpenStack nodes. So for a DC, the nodes are +vcloud-level siblings of the headend VM, and "site-down = one `virsh destroy`" is literally true only +for Office1 (whose services all live inside `voffice1`), not for a DC. + +**Model A (RECOMMENDED) -- nodes vcloud-level; site-down = destroy the domain GROUP.** +- Node VMs: vcloud-level libvirt domains on `vr1_dc0_planes` (as built). `vvr1-dc0` = a site headend VM + (the proven Office1 pattern: `cloudinit-vm` like `voffice1` + `scripts/site-headend-install.sh`, which + already names `vvr1-dc0/vvr1-dc1` as its future targets) running MAAS region+rack + LXD. `maas-vm-host` + registers vcloud's virsh -> `vvr1-dc0`'s MAAS, which discovers the nodes. +- **Site-down DR = destroy the `vr1-dc0-*` domain group** (headend + 8 nodes + edge) as a scripted op + (owned by `dc-dc-teardown-rollback.md`), NOT a single `virsh destroy`. +- Nesting depth = 2 (vcloud -> node -> KVM guest), the SAME as VR0-proven; nova-compute's KVM guests stay + shallow. Reuses ALL built tooling unchanged (`node-vm`, `maas-vm-host`, `site-headend-install.sh`). +- **Closes the "Stage-4 gap":** the per-DC MAAS "rack controller" is just a `vvr1-dc0` headend VM -- an + OpenTofu `cloudinit-vm` block shaped like `module "voffice1"` + a `site-headend-install.sh` run. + Prepare-able now (opentofu block + the Step-9 `maas-vm-host` wiring). + +**Model B -- true single-VM containment (nodes nested inside `vvr1-dc0`).** +- Site-down = one `virsh destroy vvr1-dc0` (D-122 literal). But 4-level nesting + (vcloud -> vvr1-dc0 -> node -> guest), reshapes the planes + `node-vm` to target `vvr1-dc0`'s libvirt, + contradicts the as-built D-103/D-114 discover-OpenTofu-created-nodes flow, and stresses nested-virt for + nova-compute. Heaviest delta. + +**Recommendation: Model A** -- least delta to the adopted decisions and the built tree, shallower +nesting, and it reuses the proven Office1 headend pattern verbatim. It only asks that D-122's DR wording +be read as "destroy the site's domain GROUP" for DCs (true single-VM destroy remains literal for +Office1). **What the operator rules:** Model A vs B (one line). **Related:** D-122 (site shape), D-114 +(containment scope), D-103 (OpenTofu/MAAS seam), and `scripts/site-headend-install.sh` (the reusable +per-site headend installer). + +## D-124: the Office1-region <-> DC-rack management overlay -- addressing for the MAAS control path + +**Status:** ADOPTED (2026-07-16, operator ruling). **Scheme A** (transit-numbered mesh) + rack sizing +**4 vCPU / 8192 MiB / 80 GiB** confirmed. The office1<->dc0 mesh leg is a point-to-point transit +(region <-> rack); metal-admin stays purely node-facing; the rack straddles both. Still to assign in +office1-netbox (the apex; operator-held token, main-loop can't query it live): the specific transit +`/30`(or `/31`) CIDR + the rack's metal-admin static IP -- registered via the D-124 importer with +operator-supplied values (no literal invented in-repo). Original options + rationale retained below. + +**Why this exists (answers "why not just metal-admin space?").** The rack's NODE-facing leg IS in +metal-admin (`10.12.8.0/22`) -- that is where it serves PXE/DHCP to the vr1-dc0 nodes, correct. But the +rack must also reach the **MAAS region on Office1** (`10.10.0.20:5240`) to enroll, and: +- **D-100 rules metal-admin DC-LOCAL** -- the plane deliberately does not extend to Office1 (only the + replication plane crosses fiber), and a RULED D-100 sub-item states *"Office1<->DC fiber carries + management traffic only (MAAS/Juju/operator)."* So the region<->rack control path is the + **`office1<->dc0` mesh leg**, not metal-admin. +- The rack may NOT sit on Office1's site LAN (`10.10.0.0/22`) either -- that breaks site isolation (D-122). + +So `vvr1-dc0` **straddles two legs**: metal-admin (node-facing, existing space) + the office1<->dc0 mesh +(region-facing). The mesh leg is today unnumbered isolated L2; this decision numbers it. (Same applies to +office1<->dc1 for the second DC later; dc0<->dc1 stays replication-only, D-108 -- NOT management.) + +**The addressing choice (operator rules A or B).** Both keep metal-admin DC-local at L2 and both need only +a tiny link; they differ in whether the rack carries a second address: +- **(A) Transit-numbered mesh (recommended).** The office1<->dc0 mesh leg gets a small point-to-point + transit (a `/30` or `/31`): Office1 region on one end, rack on the other. The region routes to the + rack; metal-admin stays purely node-facing. Cleanest role separation, standard inter-site practice, + and it generalizes per-leg (office1<->dc1 gets its own transit). The rack has 2 IPs (transit + metal-admin). +- **(B) Metal-admin routed over the mesh.** No separate transit subnet; the rack's single metal-admin IP + is advertised to the region over an unnumbered/`/31` mesh link. Fewer addresses (your instinct -- one + rack identity), but it couples metal-admin reachability across the fiber, a soft touch on "DC-local." + +**Where the numbers come from.** Like D-115/D-117, this decision rules the SCHEME; NetBox (office1-netbox, +the IPAM apex) assigns the actual transit CIDR + the rack's metal-admin IP -- no literal is invented here. +A candidate management/transit supernet (its own role, mirroring the D-115 Edge role) is a NetBox call; the +importer will register whatever is ruled. The rack's metal-admin IP lands in the D-120 static band +(`.2-.49`) of `10.12.8.0/22`, not the node band. + +**Rack-controller sizing (propose to confirm).** A MAAS rack controller is lightweight (it proxies images +from the region, runs no PostgreSQL): **4 vCPU / 8192 MiB / 80 GiB** is the proposed default -- far below +voffice1's region+rack+LXD 16/32/600. Confirm or adjust. + +**What the operator rules:** (1) mesh management addressing = A (transit) or B (metal-admin routed); +(2) rack sizing. Then: ratify the scheme -> NetBox assigns the transit + rack IPs (a small importer, the +D-120 pattern) -> `vvr1-dc0` (cloudinit-vm on the two legs) + `maas-vm-host` (Step 9) wire against real +addresses. **Related:** D-100 (DC-local planes + mesh = management), D-101 (address families), D-115 +(Edge-role precedent for a new transit role), D-120 (static-band convention), D-123 (region+rack model), +D-107 (per-DC mirror -- why the rack needs no direct egress). diff --git a/docs/session-ledger.md b/docs/session-ledger.md index c53184b..d62e6ce 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -38,9 +38,19 @@ the pre-DC-DC controller HA/backup session). - **OPEN security rows:** SEC-001, SEC-003, SEC-004, SEC-005, SEC-006, SEC-007, **SEC-008**. (SEC-008 -- the vcloud re-enrolment key -- was MISSING from the prior block.) -- **Next-free numbers:** D = **121**, DOCFIX = 196, BUNDLEFIX = 013. - (Since the prior block: D-119 + D-120 assigned and ADOPTED, and DOCFIX-195 assigned by this change - -- so next-free advanced D 119->121 and DOCFIX 195->196.) +- **Next-free numbers:** D = **125**, DOCFIX = 196, BUNDLEFIX = 013. + (D-124 PROPOSED 2026-07-16: the Office1-region<->DC-rack management overlay addressing -- numbers the + office1<->dc0 mesh leg so the D-123 rack can reach the MAAS region. metal-admin stays DC-local (D-100); + the rack straddles metal-admin (nodes) + the mesh (region). Operator rules scheme A (transit /30-31) vs + B (metal-admin routed) + rack sizing; then NetBox assigns + the headend wires.) + (2026-07-15: D-121, D-122, D-123 assigned -- next-free advanced D 121->124. D-121 ADOPTED (node + layout = Option C; vault sub-ruling RESOLVED = v-a). D-122 ADOPTED (site shape + 2-NIC fabric-routed + edge). **D-123 ADOPTED Model A** (nodes vcloud-level; site-down = destroy the vr1-dc0-* group). MAAS + model RULED: region on Office1 + rack controller per DC (vvr1-dc0 = vr1-dc0 rack, proxies images from + the region). REMAINING (genuine Stage-4 design, NOT invented): the rack<->region management-overlay + ADDRESSING -- the office1<->dc0 mesh leg has no assigned L3 and the rack needs static IPs on mesh + + metal-admin. A NetBox D-101-family addressing pass owned by dc-dc-phase3; vvr1-dc0 + maas-vm-host stay + HELD until it lands.) - **Standing numbering rule:** never write an identifier-shaped token (D-/DOCFIX-/BUNDLEFIX-NNN) ABOVE the real high-water mark anywhere in `docs/` or `runbooks/` prose -- `ledger-scan` reads prose and a decoy token inflates the next-free counter. This has bitten twice. @@ -114,6 +124,92 @@ `docs/changelog-20260715-d120-load-and-iprange-tooling.md`. +**STAGE 3 IN PROGRESS (branch `dc-dc-stage3-phase2-dc-substrate`) -- two rulings landed 2026-07-15.** +Walked the phase2 runbook and surfaced its blocking decisions; the operator ruled both: +- **D-121 (ADOPTED IN PART).** VR1 makes the decorative single-unit control plane REAL: 14 services + scale 1->3 (the 12 hacluster-backed APIs + ceph-radosgw + rabbitmq + vault; `ceph-rbd-mirror` stays + 1 per D-108). This is D-009's "mechanical 1->3" pulled forward from Roosevelt -- but it is a PLACEMENT + rewrite (every app's `to:` must spread across 3 distinct hosts), not a num_units tweak. **Node layout + = Option C** (3 control + 2 compute + 3 storage per DC, 16 node VMs), ruled after a whole-host + validation PROVED it fits: measured budget 256 vCPU / 1024 GiB / 10 TiB, Option C allocates + 222 / 790 / 5.4Ti (RAM binding at 77%; model at `scratchpad/optc-calc.py`). Ruled sizing: control + 16/64GiB/150GiB, compute 12/48/100, storage 8/24/550; keep control <=64 GiB (96 GiB -> 96% RAM). + **Vault-HA sub-ruling RESOLVED = (v-a)** (2026-07-15): keep MySQL-backed (on the already-3-unit + mysql-innodb-cluster), scale to 3, re-add `vault:ha`. GATE: verify the `1.8/stable` vault charm does + HA on the MySQL backend with 3 units; if not, evaluate Raft (NOT etcd) via D-068. 3-unit vault + triples the unseal burden (intersects OPEN SEC-003). +- **D-122 (ADOPTED).** VR1 site shape: each site nested in its own containment VM (site-down/recovery + + site-to-site drills); dark fiber DC-DC-Office is East-West ONLY; each site has its own dedicated L3 + ISP uplink (build `vr1-dc0-wan` on the office1-wan pattern); DC edge follows the Office1 pattern + (2048MiB/2vCPU/nano, REST-API config, LAN faces the site network); each site its own MAAS controller. + Subsumes the DOCFIX-185 D-100/D-107 amendment note. +- **Fix-forward DONE (2026-07-15):** the phase2 runbook is reconciled to post-D-119 naming + (`vr1-dc0`/`vr1-dc1`, bare `dcN` rejected) and its obsolete Steps 4-5 rewritten to the D-113(a2) + REST-API bootstrap (the module already dropped `config_iso_path`); the D-121/D-122 rulings folded in + so its Ruling-1/Ruling-2 STOP-gates clear. Honest achievable first-run scope stays **Steps 1-8** -- + Step 9 (MAAS registration) gates on the per-DC MAAS "rack controller", now RESOLVED IN PRINCIPLE by + **D-123** (PROPOSED, recommend Model A): it is just a `vvr1-dc0` site headend VM (the proven Office1 + pattern -- `cloudinit-vm` like `voffice1` + `scripts/site-headend-install.sh`, which already names + `vvr1-dc0` as a target). Once Model A is ruled it is prepare-able (opentofu block + Step-9 wiring), + which effectively closes the Stage-4 gap for Step 9. D-123 also reconciles the D-122 "one virsh + destroy = site-down" wording (literal for Office1; for a DC, site-down = destroy the `vr1-dc0-*` + domain group). See `docs/changelog-20260715-d121-d122-stage3-ha-nodelayout.md`. +- **EDGE NIC MODEL RULED (D-122 refinement, 2026-07-15): 2-NIC, baremetal-matched.** NIC count mirrors + the Roosevelt hardware -- **nodes = 6 NICs** (one per plane, measured on VR0 openstack0-3), **edge = 2** + (WAN + LAN). The six planes are **routed by the fabric** (OVN/OpenStack on the deliberately isolated-L2 + `dc-planes` segments), so the edge is NOT an inter-plane router -- only the external boundary. No + `opnsense-edge` module extension needed. Edge **WAN = `vr1-dc0-wan`** (`172.30.2.0/24`), edge + **LAN = provider-public** (`10.12.4.0/22`; edge is the external gateway per D-100). This SUPERSEDES the + earlier "build an office1-network-analog site LAN" flag -- no new site-internal segment is needed. +- **STAGE-3 PREP BATCH DONE 2026-07-15 (authoring + offline validation; NOTHING applied).** See + `docs/changelog-20260715-stage3-prereqs-prep.md`. Gauntlet ALL GREEN (61), `tofu validate` Success, + repo-lint 0-fail. + - **OpenTofu:** new `modules/site-wan` (NAT /24 uplink; schema confirmed via `tofu providers schema`, + validated) + `main.tf` Stage-3 section wired (NOT APPLIED): `vr1_dc0_wan` (172.30.2.0/24), the 2-NIC + edge (LAN=provider-public, WAN=uplink), 8 Option C node VMs via `for_each` (metal-admin PXE-first), + netem HELD (Step 11; mesh bridge = virbr7 measured), stale `vr1_dc1_planes` comment fixed. **HELD + pending D-123:** the `vvr1-dc0` headend + `maas-vm-host` (Step 9) blocks. + - **NetBox:** `netbox/dc-edge-wan-import.py` + harness (58/58) -- registers the two DC-edge /24s + (172.30.2/3.0/24, role edge, site-scoped), DRY-BY-DEFAULT with whole-plan preflight. READY to + `--commit` (command in the tool header; token operator-held; guard blocked the live read). + - **Bundle HA overlay:** `overlays/dc-ha-scaleup.yaml` (Stage-5 prep) -- 14 services ->3, one-per- + control-node placement (`{{VR1_DC0_CONTROL_0N}}` tokens, Stage-4-bind), + 3 correctness items the + agent caught: rabbitmq `min-cluster-size:3`, hacluster `cluster_count:3`, re-declare `vault-hacluster` + (BUNDLEFIX-002 had commented it out) + re-add `vault:ha`. + - **The failed opentofu agent** (transient API stall, wrote nothing) was covered in-session by the main + loop; the netbox + overlay agents completed clean. +- **D-123/D-124 RULED + `vvr1-dc0` RACK WIRED (2026-07-16).** D-123 Model A + MAAS region-on-Office1 + + rack-per-DC; D-124 Scheme A (office1<->dc0 mesh transit /30) + rack sizing 4/8192/80. `main.tf` now + wires **`module "vvr1_dc0"`** (cloudinit-vm, 4/8192/80, two legs: metal-admin + mesh-office1 transit, + static IPs from NEW tfvars `vr1_dc0_rack_*` -- NetBox-assigned, not invented; interface-naming trap + flagged) + a `mesh-link` `network_name` output. `tofu validate` Success; opentofu-validate 11/11. + **Step-9 `maas-vm-host` STAYS DEFERRED (DOCFIX-179):** adding the `provider "maas"` block would force + every plan (incl. the Steps 1-8 apply) to demand MAAS creds, and vr1-dc0's MAAS doesn't exist until + vvr1-dc0 is up -- wire it as its own step once the rack MAAS is reachable. The rack MAAS INSTALL needs + a rack-only mode of `site-headend-install.sh` (follow-up). +- **D-124 rack-mgmt importer BUILT (2026-07-16):** `netbox/dc-rack-mgmt-import.py` + harness (96/96), + parameterized (`--transit-cidr`/`--rack-ip`, no invented literal), whole-plan preflight. Gauntlet 62. + **Two operator preconditions it flags before `--commit`:** (a) SEED a dedicated `transit` role in + office1-netbox (none exists; `infra` is shared, not reused) -- die-if-absent, like `edge`; (b) DECIDE + the transit supernet -- the importer assumes it nests under `10.12.0.0/16` (Cloud), but D-124 mirrors + the Edge role (which got its OWN `172.30.0.0/16`), so a dedicated mgmt/transit supernet may be wanted + (then a one-line `CONTAINER` const update). Transit prefix scope = site vr1-dc0 (region may be preferred). +- **RESIDUAL for a smooth batch deploy:** (1) NetBox: seed the `transit` role + decide the transit + supernet, then run the D-124 importer (+ the D-115 DC-edge importer) `--commit` with the assigned CIDR/ + IP, then fill the `vr1_dc0_rack_*` tfvars; (2) runtime: `opnsense-prep-image.sh` for the edge nano; + (3) Step-9 maas-vm-host wiring once the rack MAAS is up (**the rack-only `site-headend-install.sh --role + rack` mode is now DONE, 32/32** -- it enrolls a DC rack to Office1's region; `maas init rack` confirmed); + (4) Stage-4: render the HA overlay's control-node machine IDs + `--overlay --dry-run`; (5) D-121 + verifies (vault MySQL-HA at 3; hacluster cluster_count). All operator-gated / Stage-4. +- **`main.tf` stale comment (flagged):** the commented `vr1_dc1_planes` block (~L38-50) still says + "wait for NetBox to assign D-101's supernet" -- STALE per D-115 (assigned `10.12.64.0/19`). Doc-only + pass did not touch `main.tf`; the `main.tf` owner should correct the comment. +- **STALE gate corrected:** the runbook + `main.tf` say `vr1-dc1` is blocked on "NetBox assigning the + supernet" -- D-115 ALREADY assigned it (`10.12.64.0/19`, imported to office1-netbox). vr1-dc1 stays + out of scope this pass by SEQUENCING, not a missing literal. Do NOT uncomment `vr1_dc1_planes`. +- **Logged, NOT executed (adjacent improvement, hard rule 1):** promote `scratchpad/optc-calc.py` into + a committed whole-host capacity calculator with a harness (sibling to `dc-dc-ceph-disk-budget.sh`). + The authoritative per-stage tracker is `docs/dc-dc-deployment-workflow.md` (stage table + tooling gap register). Read it FIRST. This list is only what that doc does not already carry. diff --git a/netbox/dc-edge-wan-import.py b/netbox/dc-edge-wan-import.py new file mode 100644 index 0000000..d8c4033 --- /dev/null +++ b/netbox/dc-edge-wan-import.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Register the VR1 DC-edge simulated-ISP WAN /24s in office1-netbox (the VR1 apex). + +D-115 (ADOPTED) carved a v4 Edge role at 172.30.0.0/16 and blessed office1-wan as +172.30.1.0/24 (site vr1-off1). The two DC edges follow the same role, one /24 each: + + 172.30.2.0/24 role edge, active scope=dcim.site:vr1-dc0 (vr1-dc0 sim-ISP WAN) + 172.30.3.0/24 role edge, active scope=dcim.site:vr1-dc1 (vr1-dc1 sim-ISP WAN) + +These were NOT in the D-115 office carve (which only owned the office row) and no +prior import covered the DC-edge /24s -- so this tool registers them, and nothing +else. It creates NEITHER the Edge role NOR the 172.30.0.0/16 container NOR the DC +sites: all three are PRECONDITIONS (d115-office-carve.py owns the role + container; +the vr1-dc0/vr1-dc1 sites already exist in the apex). A missing precondition is a +hard STOP, never a silent create. + +DRY BY DEFAULT -- nothing is written without --commit. + +WRITING UPSTREAM IS GATED IN CODE. SANDBOX_HOSTS is the allowlist; a --commit at +any other host REFUSES without --yes-write-upstream. WHOLE-PLAN PREFLIGHT: the role, +the container, the in-container placement of every /24, and every target site are all +checked BEFORE any create, so a bad plan cannot half-write the apex (the failure that +bit roles-aggregates-import.py -- it created 4 objects then died on the 5th). + +Usage (on office1-netbox / through a tunnel, with the sandbox token): + NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN= python3 netbox/dc-edge-wan-import.py + ... same, add --commit, to write. +""" +import argparse +import ipaddress +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +UA = "curl/8.5.0" # upstream 403s the default python UA -- see platform-traps.md + +# A sandbox is local, or the known Office1 sandbox address. Anything else is treated +# as the production apex and requires the explicit upstream flag. +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"} + +ROLE_SLUG = "edge" # created by d115-office-carve.py; a precondition here +CONTAINER = "172.30.0.0/16" # Edge role container; a precondition here +STATUS = "active" # a real segment on the wire, like office1-wan .1/24 + +# (cidr, target-site slug, description). Site-scoped exactly like office1-wan +# 172.30.1.0/24 -> vr1-off1 (d115-office-carve.py). The DC sites already exist in the +# apex draft (vr1-dc0 / vr1-dc1); this tool resolves them, it never creates them. +TARGETS = [ + ("172.30.2.0/24", "vr1-dc0", "vr1-dc0 simulated-ISP edge WAN (D-115)"), + ("172.30.3.0/24", "vr1-dc1", "vr1-dc1 simulated-ISP edge WAN (D-115)"), +] + + +def die(msg: str): + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(2) + + +class NB: + """Stdlib NetBox client -- same shape as the other sandbox-loop tools; UA-aware so + it is not 403'd by the upstream User-Agent filter.""" + def __init__(self, base, token): + self.base = base.rstrip("/") + self.token = token + + def _req(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(f"{self.base}/api/{path}", data=data, method=method, + headers={"Authorization": f"Token {self.token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=45) as r: + return json.load(r) if r.status != 204 else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace")[:300] + if exc.code == 403 and "v1 token" in detail: + die("403 'Invalid v1 token' -- NetBox 4.6 wants the ASSEMBLED v2 token " + "nbt_., not the API's bare `token` field.") + if exc.code == 403: + die(f"403 on {path}. If curl works with this token, it is the upstream " + f"User-Agent filter, NOT the token (references/platform-traps.md).") + die(f"HTTP {exc.code} {method} {path}: {detail}") + + def one(self, path, **flt): + res = self._req("GET", f"{path}/?{urllib.parse.urlencode(flt)}&limit=1") + return res["results"][0] if res["results"] else None + + def create(self, path, payload): + return self._req("POST", f"{path}/", payload) + + +def get_nb(base, token): + """Client factory -- the injection seam the harness overrides to drive main() + against an in-memory fake without a live NetBox.""" + return NB(base, token) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + ap.add_argument("--commit", action="store_true", + help="WRITE. Default is a DRY RUN that writes nothing.") + ap.add_argument("--yes-write-upstream", action="store_true", + help="Required (with --commit) to write to a NON-sandbox NetBox.") + args = ap.parse_args() + + url = os.environ.get("NETBOX_URL") + token = os.environ.get("NETBOX_TOKEN") + if not url or not token: + die("NETBOX_URL and NETBOX_TOKEN must be set.") + + host = (urllib.parse.urlparse(url).hostname or url).lower() + is_sandbox = host in SANDBOX_HOSTS + print(f"Target : {url} ({'SANDBOX' if is_sandbox else 'NOT a known sandbox'})") + if args.commit and not is_sandbox and not args.yes_write_upstream: + die(f"REFUSING to --commit to '{host}': not a known sandbox, so treated as the " + f"PRODUCTION apex. Re-run with --yes-write-upstream if that is intended.") + + print("\n*** DRY RUN -- nothing will be written. Re-run with --commit. ***" + if not args.commit else "\n*** COMMITTING. ***") + + nb = get_nb(url, token) + + # WHOLE-PLAN PREFLIGHT -- validate the ENTIRE plan (role, container, in-container + # placement, every target site) BEFORE any create, so a later-target failure (e.g. + # a missing vr1-dc1 site) cannot leave vr1-dc0 written and the apex half-populated. + container = ipaddress.ip_network(CONTAINER) + for cidr, _site_slug, _desc in TARGETS: + if not ipaddress.ip_network(cidr).subnet_of(container): + die(f"{cidr} is outside the Edge container {CONTAINER} -- refusing to place it.") + + role = nb.one("ipam/roles", slug=ROLE_SLUG) + if role is None: + die(f"role '{ROLE_SLUG}' absent -- seed the D-115 carve first " + f"(netbox/d115-office-carve.py). Refusing to place edge /24s with no role.") + + if nb.one("ipam/prefixes", prefix=CONTAINER) is None: + die(f"Edge container {CONTAINER} absent -- seed the D-115 carve first " + f"(netbox/d115-office-carve.py). Refusing to place /24s in an unallocated /16.") + + # Resolve every site and its already-present state up front -- THIS is the whole-plan + # gate: if any site is missing the run dies here, before the create loop below runs. + plan = [] + for cidr, site_slug, desc in TARGETS: + site = nb.one("dcim/sites", slug=site_slug) + if site is None: + die(f"site '{site_slug}' absent -- cannot scope {cidr}. The DC sites are a " + f"precondition (they already exist in the apex); this tool never creates them.") + present = nb.one("ipam/prefixes", prefix=cidr) is not None + plan.append((cidr, site, desc, present)) + + created = existing = 0 + + print("\nEdge DC /24s:") + for cidr, site, desc, present in plan: + if present: + print(f" EXISTS {cidr}") + existing += 1 + continue + if not args.commit: + print(f" [dry-run] would CREATE {cidr} role={ROLE_SLUG} " + f"scope=dcim.site:{site['slug']}") + created += 1 + continue + payload = {"prefix": cidr, "role": role["id"], "status": STATUS, + "description": desc, "scope_type": "dcim.site", "scope_id": site["id"]} + o = nb.create("ipam/prefixes", payload) + print(f" CREATED {cidr} (id={o['id']}) role={ROLE_SLUG} scope={site['slug']}") + created += 1 + + verb = "would create" if not args.commit else "created" + print(f"\n{'='*66}\n{verb}: {created} already present: {existing}") + if not args.commit: + print("DRY RUN -- nothing was written. Re-run with --commit.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/netbox/dc-rack-mgmt-import.py b/netbox/dc-rack-mgmt-import.py new file mode 100644 index 0000000..b0d41d1 --- /dev/null +++ b/netbox/dc-rack-mgmt-import.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Register the D-124 Office1-region <-> DC-rack MANAGEMENT TRANSIT addressing in +office1-netbox (the VR1 IPAM apex): the point-to-point transit on the office1<->dc0 +mesh leg, plus the rack's metal-admin static IP. Two objects, nothing else. + +D-124 (ADOPTED 2026-07-16, Scheme A -- transit-numbered mesh) rules that the +region<->rack MAAS control path rides a small point-to-point transit on the +office1<->dc0 mesh leg (NOT metal-admin, which D-100 keeps DC-local and node-facing). +The rack (vvr1-dc0) STRADDLES both legs: the transit (region-facing) and metal-admin +(node-facing, 10.12.8.0/22). So this tool registers exactly: + + 1. the transit prefix role=transit, scope=dcim.site:vr1-dc0 (the /30 or /31) + 2. the rack ip-address <rack-ip>/22 in metal-admin, D-120 static band .2-.49 + +Both LITERALS ARE OPERATOR INPUTS (--transit-cidr / --rack-ip): D-124, like D-115/ +D-117, rules the SCHEME and leaves the actual CIDR + IP to office1-netbox (the apex; +operator-held token, this loop cannot query it live). NO literal is invented in-repo. + +This tool creates NEITHER the transit ROLE nor any container/site: all are +PRECONDITIONS (a missing one is a hard STOP, never a silent create) -- exactly as +dc-edge-wan-import.py treats the `edge` role + the 172.30.0.0/16 container. + +DRY BY DEFAULT -- nothing is written without --commit. + +WRITING UPSTREAM IS GATED IN CODE. SANDBOX_HOSTS is the allowlist; a --commit at any +other host REFUSES without --yes-write-upstream. WHOLE-PLAN PREFLIGHT: the transit +role, the container the transit nests under, the transit CIDR shape+placement, the +rack IP's band (metal-admin, static .2-.49, NOT the .1 gateway), and the target site +are ALL checked BEFORE any create -- so a bad plan (e.g. a rack IP that is the .1 +gateway) cannot leave the transit prefix written and the apex half-populated (the +failure that bit roles-aggregates-import.py: 4 objects created, then it died on the 5th). + +SCOPING / ROLE choices flagged for the operator (see the D-124 return notes): + * ROLE_SLUG = "transit": D-124 calls for the transit's OWN role, "mirroring the + D-115 Edge role" (a DEDICATED role). An `infra` role exists in the apex whose + description reads "ptp transit, device loopbacks, RR loopbacks, anycast" -- it is + NOT reused here because it is explicitly SHARED infrastructure, not the dedicated + per-purpose role D-124 asks for. Operator to seed/confirm the `transit` slug in + office1-netbox (as d115-office-carve.py seeded `edge`). A missing role dies here. + * CONTAINER = "10.12.0.0/16" (Cloud): the transit CIDR must be subnet_of an EXISTING + container. The apex's only IPv4 container the transit plausibly nests under is Cloud + 10.12.0.0/16 (ipam/aggregates is empty; no dedicated management/transit supernet + exists yet). This gates ACCEPTANCE only -- NetBox auto-nests by CIDR, we set no + parent. If the operator numbers the transit outside Cloud, the tool DIES rather + than misplacing it. Update CONTAINER if a dedicated transit supernet is carved. + * transit prefix SCOPE = dcim.site:vr1-dc0. How an office1<->dc0 link "should" scope + (region vs. site) is unruled; site-scoping to vr1-dc0 mirrors the D-115 DC-edge + /24s (172.30.2.0/24 -> vr1-dc0). FLAGGED -- operator may prefer a region scope. + * The rack IP band (metal-admin 10.12.8.0/22, static .2-.49, gw .1) is enforced + ARITHMETICALLY (D-120/D-124), NOT via a prefix-object lookup: metal-admin is not + registered as a /22 prefix in the apex draft, and the band is a convention, not a + container. This asymmetry with the transit check is intentional. + +Usage (on office1-netbox / through a tunnel, with the sandbox token): + NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=<tok> \ + python3 netbox/dc-rack-mgmt-import.py --transit-cidr <A.B.C.D/30> --rack-ip <10.12.8.X> + ... same, add --commit, to write. +""" +import argparse +import ipaddress +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +UA = "curl/8.5.0" # upstream 403s the default python UA -- see platform-traps.md + +# A sandbox is local, or the known Office1 sandbox address. Anything else is treated +# as the production apex and requires the explicit upstream flag. +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"} + +ROLE_SLUG = "transit" # the transit's OWN role (D-124); a precondition here +CONTAINER = "10.12.0.0/16" # Cloud; the transit CIDR must be subnet_of this (a precondition) +SITE_SLUG = "vr1-dc0" # the transit prefix is site-scoped here (mirrors the D-115 edge /24s) +STATUS = "active" # a real segment on the wire + +# metal-admin band arithmetic (D-052/D-124). The rack's metal-admin IP lands in the +# D-120 static band .2-.49 of this /22, and NOT the .1 gateway. +METAL_ADMIN = "10.12.8.0/22" +STATIC_BAND_LOW = 2 # .2 (first static site-services address, D-120) +STATIC_BAND_HIGH = 49 # .49 (last static site-services address, D-120) + +RACK_DNS = "vvr1-dc0" # the rack-controller VM (D-124 cloudinit-vm on the two legs) +TRANSIT_DESC = "office1<->dc0 management transit -- region<->rack MAAS control path (D-124 Scheme A)" +RACK_DESC = "vr1-dc0 MAAS rack controller (vvr1-dc0) metal-admin static IP (D-124; D-120 static band)" + + +def die(msg: str): + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(2) + + +class NB: + """Stdlib NetBox client -- same shape as the other sandbox-loop tools; UA-aware so + it is not 403'd by the upstream User-Agent filter.""" + def __init__(self, base, token): + self.base = base.rstrip("/") + self.token = token + + def _req(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(f"{self.base}/api/{path}", data=data, method=method, + headers={"Authorization": f"Token {self.token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=45) as r: + return json.load(r) if r.status != 204 else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace")[:300] + if exc.code == 403 and "v1 token" in detail: + die("403 'Invalid v1 token' -- NetBox 4.6 wants the ASSEMBLED v2 token " + "nbt_<key>.<plaintext>, not the API's bare `token` field.") + if exc.code == 403: + die(f"403 on {path}. If curl works with this token, it is the upstream " + f"User-Agent filter, NOT the token (references/platform-traps.md).") + die(f"HTTP {exc.code} {method} {path}: {detail}") + + def one(self, path, **flt): + res = self._req("GET", f"{path}/?{urllib.parse.urlencode(flt)}&limit=1") + return res["results"][0] if res["results"] else None + + def create(self, path, payload): + return self._req("POST", f"{path}/", payload) + + +def get_nb(base, token): + """Client factory -- the injection seam the harness overrides to drive main() + against an in-memory fake without a live NetBox.""" + return NB(base, token) + + +def parse_transit_cidr(raw: str) -> ipaddress.IPv4Network: + """The transit CIDR must be a valid /30 or /31 network (host bits clear), subnet_of + CONTAINER. Shape is validated here; containment against CONTAINER in preflight.""" + try: + net = ipaddress.ip_network(raw, strict=True) + except ValueError as exc: + die(f"--transit-cidr {raw!r} is not a valid network (host bits set?): {exc}") + if net.version != 4: + die(f"--transit-cidr {raw} must be IPv4 (the office1<->dc0 transit is v4).") + if net.prefixlen not in (30, 31): + die(f"--transit-cidr {raw} must be a /30 or /31 point-to-point (got /{net.prefixlen}).") + return net + + +def parse_rack_ip(raw: str) -> ipaddress.IPv4Address: + """Accept a bare host (10.12.8.X) or a masked form; if masked, the mask MUST be the + metal-admin /22. Returns the host address; band placement is checked in preflight.""" + try: + if "/" in raw: + iface = ipaddress.ip_interface(raw) + if iface.network.prefixlen != 22: + die(f"--rack-ip {raw} carries /{iface.network.prefixlen}; metal-admin is a /22. " + f"Pass a bare host (10.12.8.X) or the /22 form.") + host = iface.ip + else: + host = ipaddress.ip_address(raw) + except ValueError as exc: + die(f"--rack-ip {raw!r} is not a valid IPv4 address: {exc}") + if host.version != 4: + die(f"--rack-ip {raw} must be IPv4 (metal-admin is v4).") + return host + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + ap.add_argument("--transit-cidr", default=os.environ.get("TRANSIT_CIDR"), + help="REQUIRED. The /30 or /31 point-to-point for the office1<->dc0 " + "transit leg (operator-supplied, NetBox-assigned; env: TRANSIT_CIDR).") + ap.add_argument("--rack-ip", default=os.environ.get("RACK_IP"), + help="REQUIRED. The rack's metal-admin static IP within 10.12.8.0/22, " + "static band .2-.49 (operator-supplied; env: RACK_IP).") + ap.add_argument("--commit", action="store_true", + help="WRITE. Default is a DRY RUN that writes nothing.") + ap.add_argument("--yes-write-upstream", action="store_true", + help="Required (with --commit) to write to a NON-sandbox NetBox.") + args = ap.parse_args() + + url = os.environ.get("NETBOX_URL") + token = os.environ.get("NETBOX_TOKEN") + if not url or not token: + die("NETBOX_URL and NETBOX_TOKEN must be set.") + + # Inputs are args-or-env, so they cannot be argparse required=True (that would break + # the env fallback). Hand-roll the missing checks, each with its own die. + if not args.transit_cidr: + die("--transit-cidr (or TRANSIT_CIDR) is REQUIRED -- the NetBox-assigned transit " + "/30 or /31. No literal is invented in-repo (D-124).") + if not args.rack_ip: + die("--rack-ip (or RACK_IP) is REQUIRED -- the rack's NetBox-assigned metal-admin " + "static IP. No literal is invented in-repo (D-124).") + + host = (urllib.parse.urlparse(url).hostname or url).lower() + is_sandbox = host in SANDBOX_HOSTS + print(f"Target : {url} ({'SANDBOX' if is_sandbox else 'NOT a known sandbox'})") + if args.commit and not is_sandbox and not args.yes_write_upstream: + die(f"REFUSING to --commit to '{host}': not a known sandbox, so treated as the " + f"PRODUCTION apex. Re-run with --yes-write-upstream if that is intended.") + + print("\n*** DRY RUN -- nothing will be written. Re-run with --commit. ***" + if not args.commit else "\n*** COMMITTING. ***") + + # ---- WHOLE-PLAN PREFLIGHT: validate the ENTIRE plan (both objects) BEFORE any + # create, so a bad rack IP (e.g. the .1 gateway) cannot leave the transit written. ---- + + # (a) Transit CIDR shape + containment (local, no NetBox needed). + transit = parse_transit_cidr(args.transit_cidr) + container = ipaddress.ip_network(CONTAINER) + if not transit.subnet_of(container): + die(f"transit {transit} is outside the container {CONTAINER} (Cloud) -- refusing to " + f"place it. If the transit is carved elsewhere, that is an operator/D-124 call.") + + # (b) Rack IP band: within metal-admin /22, in the D-120 static band .2-.49, NOT the + # .1 gateway (a distinct die so the ".1 rejected" property is unambiguous). + rack = parse_rack_ip(args.rack_ip) + metal = ipaddress.ip_network(METAL_ADMIN) + if rack not in metal: + die(f"rack IP {rack} is outside metal-admin {METAL_ADMIN} -- refusing to place it.") + gateway = metal.network_address + 1 + band_low = metal.network_address + STATIC_BAND_LOW + band_high = metal.network_address + STATIC_BAND_HIGH + if rack == gateway: + die(f"rack IP {rack} is the .1 GATEWAY of {METAL_ADMIN} -- refusing (D-120: .1 is the " + f"site gateway, not a static-service address).") + if not (band_low <= rack <= band_high): + die(f"rack IP {rack} is outside the D-120 static band {band_low}-{band_high} of " + f"{METAL_ADMIN} -- the rack's metal-admin IP must land in .2-.49 (not the dynamic/" + f"node bands).") + rack_addr = f"{rack}/{metal.prefixlen}" # NetBox stores the host WITH the plane mask + + nb = get_nb(url, token) + + # (c) Preconditions in the apex: the transit role, the container prefix, the site. + role = nb.one("ipam/roles", slug=ROLE_SLUG) + if role is None: + die(f"role '{ROLE_SLUG}' absent -- D-124 calls for the transit's OWN dedicated role " + f"(mirroring the D-115 `edge` role); seed it in office1-netbox first. Refusing to " + f"place the transit prefix with no role.") + + if nb.one("ipam/prefixes", prefix=CONTAINER) is None: + die(f"container {CONTAINER} (Cloud) absent in the apex -- refusing to place the transit " + f"in an unallocated supernet.") + + site = nb.one("dcim/sites", slug=SITE_SLUG) + if site is None: + die(f"site '{SITE_SLUG}' absent -- cannot scope the transit prefix. The DC site is a " + f"precondition (it already exists in the apex); this tool never creates it.") + + # (d) Present-state (idempotency) -- resolved up front, part of the whole-plan gate. + transit_str = str(transit) + transit_present = nb.one("ipam/prefixes", prefix=transit_str) is not None + rack_present = nb.one("ipam/ip-addresses", address=rack_addr) is not None + + created = existing = 0 + + # ---- CREATE (transit prefix, then rack ip-address) -- only reached once the WHOLE + # plan above is viable, so this loop cannot half-write the apex. ---- + print("\nTransit prefix (office1<->dc0 mesh leg):") + if transit_present: + print(f" EXISTS {transit_str}") + existing += 1 + elif not args.commit: + print(f" [dry-run] would CREATE {transit_str} role={ROLE_SLUG} " + f"scope=dcim.site:{SITE_SLUG}") + created += 1 + else: + payload = {"prefix": transit_str, "role": role["id"], "status": STATUS, + "description": TRANSIT_DESC, "scope_type": "dcim.site", "scope_id": site["id"]} + o = nb.create("ipam/prefixes", payload) + print(f" CREATED {transit_str} (id={o['id']}) role={ROLE_SLUG} scope={SITE_SLUG}") + created += 1 + + print("\nRack metal-admin static IP:") + if rack_present: + print(f" EXISTS {rack_addr}") + existing += 1 + elif not args.commit: + print(f" [dry-run] would CREATE {rack_addr} dns={RACK_DNS} (metal-admin static band)") + created += 1 + else: + payload = {"address": rack_addr, "status": STATUS, + "dns_name": RACK_DNS, "description": RACK_DESC} + o = nb.create("ipam/ip-addresses", payload) + print(f" CREATED {rack_addr} (id={o['id']}) dns={RACK_DNS}") + created += 1 + + verb = "would create" if not args.commit else "created" + print(f"\n{'='*66}\n{verb}: {created} already present: {existing}") + if not args.commit: + print("DRY RUN -- nothing was written. Re-run with --commit.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/opentofu/main.tf b/opentofu/main.tf index f75981d..48eb444 100644 --- a/opentofu/main.tf +++ b/opentofu/main.tf @@ -35,11 +35,14 @@ target_path = var.vr1_dc0_pool_path } -# ---- DC2: PLANES deferred (operator ruling 2026-07-10, Option B) -- wait for -# NetBox to assign D-101's supernet/ULA/GUA before wiring vr1_dc1_planes. Do NOT -# fill in guessed CIDRs to make it "work" sooner. When NetBox assigns it, add a -# `vr1_dc1_planes` variable (same shape as `vr1_dc0_planes`) and uncomment this block. -# The storage pool + mesh legs are NOT address-dependent, so they ARE wired now. +# ---- vr1-dc1 (VR1's SECOND DC): PLANES deferred by SEQUENCING, not a missing +# literal. Its supernet IS assigned -- D-115 moved it to 10.12.64.0/19 (ADOPTED, +# imported to office1-netbox), superseding the old "wait for NetBox" premise. It +# stays out of scope until vr1-dc0 completes (operator ruling 2026-07-10, Option B: +# vr1-dc0-first). When vr1-dc1 is started, add a `vr1_dc1_planes` variable (same +# shape as `vr1_dc0_planes`, CIDRs derived per D-115) and uncomment this block. +# Do NOT uncomment it now. The storage pool + mesh legs are NOT address-dependent, +# so they ARE wired now. # # module "vr1_dc1_planes" { # source = "./modules/dc-planes" @@ -266,3 +269,171 @@ from = module.mesh_dc2_office1 to = module.mesh_vr1_dc1_office1 } + +# ===================================================================== +# STAGE 3 -- vr1-dc0 substrate (runbooks/dc-dc-phase2-tofu-dc-substrate.md). +# Authored 2026-07-15. NOT YET APPLIED -- every block below is a gated Stage-3 +# mutation; `tofu apply` is the operator's step (Steps 8/11 of the runbook). +# Governing rulings: D-100 (edge/fabric), D-121 (Option C node layout + sizing), +# D-122 (2-NIC fabric-routed edge; dedicated per-site ISP uplink). +# +# D-123 Model A + MAAS model RULED (region on Office1 + rack per DC) + D-124 +# ADOPTED (Scheme A: office1<->dc0 mesh transit; rack sizing 4/8192/80). The +# `vvr1-dc0` rack controller IS wired below -- its two static IPs come from +# office1-netbox via variables (tfvars), NOT invented here. The Step-9 +# `maas-vm-host` registration is STILL DEFERRED (DOCFIX-179): adding the +# `provider "maas"` block it needs would force EVERY plan (incl. the Steps 1-8 +# substrate apply) to demand MAAS creds, and vr1-dc0's MAAS does not exist until +# vvr1-dc0 is up and its rack is installed. Wire maas-vm-host + the maas provider +# as its own step once the rack MAAS is reachable (runbook Step 9). The blocks +# below are the D-123-independent substrate (D-103/D-114 as-built) plus the rack. +# ===================================================================== + +# D-122: the dedicated per-site simulated-ISP uplink (172.30.2.0/24, D-115). +module "vr1_dc0_wan" { + source = "./modules/site-wan" + network_name = "vr1-dc0-wan" + cidr = "172.30.2.0/24" + # mtu defaults to 1500 (ISP-uplink domain; NOT the jumbo planes/mesh). +} + +# D-122: the DC edge -- 2-NIC (WAN + LAN), Office1 pattern (2048/2/nano). The six +# planes are fabric-routed (OVN/OpenStack), so the edge is only the external +# boundary: LAN = provider-public (edge is its external gateway, D-100), WAN = +# the dedicated uplink above. +module "vr1_dc0_opnsense" { + source = "./modules/opnsense-edge" + vm_name = "vr1-dc0-opnsense" + memory_mib = 2048 + vcpu = 2 + pool_name = module.vr1_dc0_storage.pool_name + # No disk_size_bytes: nano direct-copy, sized by opnsense-prep-image.sh (DOCFIX-189). + # PREREQUISITE (runbook Step 4): the prepped nano image must exist at this path. + base_volume_path = "/var/lib/libvirt/vr1/vr1-dc0/opnsense-26.1-nano.qcow2" + lan_network_name = module.vr1_dc0_planes.network_names["provider-public"] + wan_network_name = module.vr1_dc0_wan.network_name +} + +# D-121 Option C node layout: 3 control + 2 compute + 3 storage (16/12/8 vCPU, +# 64/48/24 GiB, 150/100/550 GiB). Six NICs each (one per plane, baremetal-matched +# D-122); metal-admin FIRST = the PXE/boot plane (D-052 default binding). +locals { + vr1_dc0_node_nics = [ + module.vr1_dc0_planes.network_names["metal-admin"], # PXE / boot fabric first + module.vr1_dc0_planes.network_names["provider-public"], + module.vr1_dc0_planes.network_names["metal-internal"], + module.vr1_dc0_planes.network_names["data-tenant"], + module.vr1_dc0_planes.network_names["storage"], + module.vr1_dc0_planes.network_names["replication"], + ] + + vr1_dc0_nodes = { + "vr1-dc0-control-01" = { vcpu = 16, mem = 65536, disk_gib = 150 } + "vr1-dc0-control-02" = { vcpu = 16, mem = 65536, disk_gib = 150 } + "vr1-dc0-control-03" = { vcpu = 16, mem = 65536, disk_gib = 150 } + "vr1-dc0-compute-01" = { vcpu = 12, mem = 49152, disk_gib = 100 } + "vr1-dc0-compute-02" = { vcpu = 12, mem = 49152, disk_gib = 100 } + "vr1-dc0-storage-01" = { vcpu = 8, mem = 24576, disk_gib = 550 } + "vr1-dc0-storage-02" = { vcpu = 8, mem = 24576, disk_gib = 550 } + "vr1-dc0-storage-03" = { vcpu = 8, mem = 24576, disk_gib = 550 } + } +} + +module "vr1_dc0_node" { + for_each = local.vr1_dc0_nodes + + source = "./modules/node-vm" + vm_name = each.key + vcpu = each.value.vcpu + memory_mib = each.value.mem + disk_size_bytes = each.value.disk_gib * 1024 * 1024 * 1024 + pool_name = module.vr1_dc0_storage.pool_name + network_names = local.vr1_dc0_node_nics +} + +# netem (runbook Step 11) -- HELD as a comment. It is a POST-apply step that runs +# SSH-wrapped provisioners from Office1 to the vcloud host, so it needs a real +# `vcloud_host_ssh_target` (a runtime value) and the netem params are an unruled +# D-100 sub-item (gap #11, placeholder only). Measured input ready: the +# vr1-dc0<->office1 mesh leg is bridge `virbr7`. Wire at Step 11, not before. +# module "netem_vr1_dc0_office1" { +# source = "./modules/netem-link" +# link_name = "vr1-dc0-office1" +# bridge_name = "virbr7" # measured 2026-07-15 +# vcloud_host_ssh_target = "<real ssh target -- runtime>" +# netem_args = "<PLACEHOLDER: buildout-design S6 same-metro lean; D-100 gap #11 unruled>" +# } + +# ===================================================================== +# D-123 / D-124: the vr1-dc0 MAAS RACK CONTROLLER (vvr1-dc0). An Ubuntu VM (reusing +# the noble base) that will run a MAAS rack controller enrolled to the Office1 MAAS +# REGION. Two legs: metal-admin (serves PXE/DHCP to the vr1-dc0 nodes) + the +# office1<->dc0 mesh transit (reaches the region, D-124 Scheme A). It PROXIES OS +# images from the region, so it needs no direct DC egress (D-107). NOT YET APPLIED. +# The MAAS rack INSTALL itself is a gated post-boot step (an adapted +# site-headend-install.sh rack-only mode -- follow-up), like voffice1's install. +# ===================================================================== +module "vvr1_dc0" { + source = "./modules/cloudinit-vm" + vm_name = "vvr1-dc0" + vcpu = 4 # D-124 (rack controller: lightweight) + memory_mib = 8192 # D-124 + disk_size_bytes = 80 * 1024 * 1024 * 1024 # D-124: 80 GiB + pool_name = module.vr1_dc0_storage.pool_name + base_volume_path = module.ubuntu_noble_base.path + expose_nested_virt = false # rack-only: the region + its LXD host compose VMs, not the rack + + network_names = [ + module.vr1_dc0_planes.network_names["metal-admin"], # leg 1: node-facing PXE/DHCP + module.mesh_vr1_dc0_office1.network_name, # leg 2: office1<->dc0 transit (region) + ] + + user_data = <<-EOT + #cloud-config + hostname: vvr1-dc0 + fqdn: vvr1-dc0.${var.domain_suffix} + manage_etc_hosts: true + users: + - name: jessea123 + groups: [adm, sudo] + shell: /bin/bash + sudo: "ALL=(ALL) NOPASSWD:ALL" + ssh_authorized_keys: + - ${trimspace(file(var.office1_ssh_pubkey_path))} + package_update: true + packages: + - qemu-guest-agent + runcmd: + - [systemctl, enable, --now, qemu-guest-agent] + EOT + + meta_data = <<-EOT + instance-id: vvr1-dc0-d123 + local-hostname: vvr1-dc0 + EOT + + # STATIC on both legs (planes + mesh have NO DHCP). IPs are NetBox-assigned + # (D-124), supplied via tfvars -- no literal here. INTERFACE-NAMING TRAP: the + # guest NIC names (enp1s0/enp2s0 vs ens3/...) depend on machine type and are NOT + # known until first boot -- CONFIRM on boot and adjust `match` before relying on + # this (documented trap; modules/opnsense-edge + the runbook). metal-admin gives + # the rack no default route; the region route rides the transit toward Office1 + # (10.10.0.0/22, as-built D-115). + network_config = <<-EOT + version: 2 + ethernets: + admin: + match: + name: "enp1s0" + set-name: admin + addresses: ["${var.vr1_dc0_rack_metal_admin_ip}/22"] + mgmt: + match: + name: "enp2s0" + set-name: mgmt + addresses: ["${var.vr1_dc0_rack_transit_ip}/${var.vr1_dc0_rack_transit_prefix}"] + routes: + - to: "10.10.0.0/22" + via: "${var.vr1_dc0_rack_transit_peer_ip}" + EOT +} diff --git a/opentofu/modules/mesh-link/outputs.tf b/opentofu/modules/mesh-link/outputs.tf index 91f8bcc..097e972 100644 --- a/opentofu/modules/mesh-link/outputs.tf +++ b/opentofu/modules/mesh-link/outputs.tf @@ -3,6 +3,11 @@ value = libvirt_network.link.id } +output "network_name" { + description = "libvirt network name (mesh-<link_name>) -- for a consumer's network_names list (e.g. the vr1-dc0 rack's transit NIC, D-124)." + value = libvirt_network.link.name +} + # A "bridge_name" output (the OS-level bridge interface backing this network, # needed by whatever applies `tc netem` to it) is deliberately NOT included # here: `bridge` is documented as a configurable input block (delay, diff --git a/opentofu/modules/site-wan/main.tf b/opentofu/modules/site-wan/main.tf new file mode 100644 index 0000000..8b7c3c0 --- /dev/null +++ b/opentofu/modules/site-wan/main.tf @@ -0,0 +1,36 @@ +# site-wan: NAT'd per-site ISP uplink (see variables.tf for rationale). +# +# SCHEMA NOTE: this provider (registry.opentofu.org/dmacvicar/libvirt) uses +# attribute-style nested objects, confirmed 2026-07-15 via +# `tofu providers schema -json` (the method dc-planes/main.tf calls for): +# forward = { mode = "nat" } (nesting=single; no top-level `mode`) +# ips = [ { address, prefix } ] (nesting=list) +# mtu = { size = N } (nesting=single; matches dc-planes) +# domain = { name = ... } (nesting=single; matches dc-planes) +# This mirrors the live `office1-wan` (virsh): <forward mode='nat'>, .1/24. +# DHCP is intentionally OMITTED -- the only client is the edge, whose WAN is +# static (office1-opnsense's WAN is .2, outside office1-wan's DHCP range). + +resource "libvirt_network" "site_wan" { + name = var.network_name + autostart = true + + forward = { + mode = "nat" + } + + domain = { + name = var.network_name + } + + mtu = { + size = var.mtu + } + + ips = [ + { + address = cidrhost(var.cidr, 1) + prefix = tonumber(split("/", var.cidr)[1]) + } + ] +} diff --git a/opentofu/modules/site-wan/outputs.tf b/opentofu/modules/site-wan/outputs.tf new file mode 100644 index 0000000..1f260c0 --- /dev/null +++ b/opentofu/modules/site-wan/outputs.tf @@ -0,0 +1,4 @@ +output "network_name" { + description = "The libvirt network name, for a consumer's wan_network_name." + value = libvirt_network.site_wan.name +} diff --git a/opentofu/modules/site-wan/variables.tf b/opentofu/modules/site-wan/variables.tf new file mode 100644 index 0000000..8a11535 --- /dev/null +++ b/opentofu/modules/site-wan/variables.tf @@ -0,0 +1,31 @@ +# site-wan: a per-site simulated-ISP uplink network (D-100 / D-122). NAT'd /24, +# the site edge's WAN leg attaches here. Generalizes the virsh-created +# `office1-wan` (172.30.1.0/24) into a reusable module so every per-site ISP +# uplink is tofu-managed (D-122 / operator 2b, 2026-07-15). +# +# MTU is the ISP-uplink domain: 1500, NOT the jumbo 9000 the planes/mesh use. +# This is a hard distinction -- the simulated ISP link mirrors a real 1500-MTU +# internet uplink (see the two-MTU-domains note in the session ledger). Do not +# raise this to 9000. + +variable "network_name" { + description = "libvirt network name, e.g. \"vr1-dc0-wan\"." + type = string +} + +variable "cidr" { + description = <<-EOT + The uplink /24, e.g. "172.30.2.0/24" (Edge role 172.30.0.0/16, D-115). + The network gateway is the .1 host of this CIDR (cidrhost(cidr, 1)); the + site edge's WAN interface takes a STATIC address on this segment (e.g. .2), + configured on the edge itself via the REST API -- not by DHCP here (the + edge WAN is static, exactly as office1-opnsense's WAN 172.30.1.2 is). + EOT + type = string +} + +variable "mtu" { + description = "Uplink MTU. Default 1500 (the ISP-uplink domain; NEVER jumbo)." + type = number + default = 1500 +} diff --git a/opentofu/modules/site-wan/versions.tf b/opentofu/modules/site-wan/versions.tf new file mode 100644 index 0000000..f50888b --- /dev/null +++ b/opentofu/modules/site-wan/versions.tf @@ -0,0 +1,11 @@ +# Provider source mapping for this module (DOCFIX-179). Child modules MUST +# declare their own required_providers so OpenTofu maps the local name +# `libvirt` to dmacvicar/libvirt; version is pinned once at the ROOT +# (opentofu/versions.tf), child modules declare source only. +terraform { + required_providers { + libvirt = { + source = "dmacvicar/libvirt" + } + } +} diff --git a/opentofu/variables.tf b/opentofu/variables.tf index 25e0aa3..9718811 100644 --- a/opentofu/variables.tf +++ b/opentofu/variables.tf @@ -96,3 +96,28 @@ description = "voffice1 boot disk size in bytes." type = number } + +# ---- D-124: vr1-dc0 MAAS rack controller (vvr1-dc0) addressing. NO defaults -- +# these come from office1-netbox (the IPAM apex) via the D-124 importer, then +# into a tfvars entry. Do NOT invent them here (hard rule 2). The rack straddles +# two legs: metal-admin (node-facing PXE/DHCP) + the office1<->dc0 mesh transit +# (region-facing, Scheme A). ---- +variable "vr1_dc0_rack_metal_admin_ip" { + description = "vvr1-dc0 rack static IP in metal-admin (10.12.8.0/22), in the .2-.49 static band, not the .1 gateway. NetBox-assigned (D-124)." + type = string +} + +variable "vr1_dc0_rack_transit_ip" { + description = "vvr1-dc0 rack IP on the office1<->dc0 mesh transit (D-124 Scheme A). NetBox-assigned." + type = string +} + +variable "vr1_dc0_rack_transit_prefix" { + description = "Transit link prefix length (30 or 31, D-124 Scheme A)." + type = number +} + +variable "vr1_dc0_rack_transit_peer_ip" { + description = "Office1 region's IP on the transit link -- the rack's next-hop toward the MAAS region (10.10.0.0/22). NetBox-assigned (D-124)." + type = string +} diff --git a/overlays/dc-ha-scaleup.yaml b/overlays/dc-ha-scaleup.yaml new file mode 100644 index 0000000..4262fb9 --- /dev/null +++ b/overlays/dc-ha-scaleup.yaml @@ -0,0 +1,183 @@ +# overlays/dc-ha-scaleup.yaml +# +# D-121 (ADOPTED IN PART, 2026-07-15) VR1 HA scale-up overlay -- makes the +# decorative single-unit control plane REAL by scaling 14 services from +# num_units: 1 to 3. VR1-SPECIFIC: VR0/testcloud stays single-unit BY DESIGN +# (D-009), so this is an overlay, NOT a bundle.yaml edit -- same pattern as +# overlays/dc-dc-ipv6-family-matrix.yaml. Apply ALONGSIDE bundle.yaml's other +# overlays, e.g.: +# juju deploy ./bundle.yaml \ +# --overlay overlays/octavia-pki.yaml \ +# --overlay "overlays/${DC}-hostnames.yaml" \ +# --overlay overlays/dc-ha-scaleup.yaml \ +# -m "${DC_MODEL}" +# +# NOT YET APPLIED to any live model -- authored prep-only (STAGE 5 prep; the +# per-DC bundle deploy is two stages out). No live cloud reached this session. +# +# ===================================================================== +# ##### STAGE-4 BINDING REQUIRED -- READ BEFORE RENDER ##### +# Every `to:` target below is a {{TOKEN}} placeholder for one of the three +# Option C control nodes (vr1-dc0-control-01..03, D-121). The REAL Juju machine +# IDs do NOT exist yet -- they bind at Stage 4, AFTER MAAS enlists the Option C +# node VMs. Do NOT invent concrete `lxd:N` numbers here: a wrong number +# silently mis-places a unit and defeats the anti-affinity that is the entire +# HA value. Render this file (substitute the three tokens with the measured, +# enlisted control-node machine IDs) ONLY at Stage 4/5, exactly as +# dc-dc-ipv6-family-matrix.yaml's VIP tokens are rendered. The rendered +# bundle.yaml `machines:` block MUST define those same three IDs. +# {{VR1_DC0_CONTROL_01}} -> control node 1 machine id +# {{VR1_DC0_CONTROL_02}} -> control node 2 machine id +# {{VR1_DC0_CONTROL_03}} -> control node 3 machine id +# ===================================================================== +# +# PLACEMENT / ANTI-AFFINITY (D-121 balance rule). Juju bundles have NO dedicated +# anti-affinity primitive; one-per-node is expressed POSITIONALLY -- a 3-entry +# `to:` list binds unit0->entry0, unit1->entry1, unit2->entry2. Every scaled app +# below uses the SAME three-control-node spread, so: +# - each app lands exactly one unit per control node (14 apps x 1 = ~14 API +# LXD containers per control node -- the even ~14/node density the 64 GiB +# control-node sizing was validated for, D-121); and +# - the RAM-heavy stateful trio (mysql-innodb-cluster already 3 in the base +# bundle, rabbitmq-server + vault scaled here) auto-balances to one replica +# per control node -- never two stacked on one node. +# +# THREE ITEMS BELOW GO BEYOND A LITERAL `num_units: 3` BUMP. They are NOT +# scope-creep: each is the same class of "mechanical 1->3 hides a gotcha" that +# D-121 itself flags (D-121 caught the placement rewrite; these are the two it +# under-states plus the one its own wording got wrong), and an overlay titled +# "make HA real" that shipped decorative HA would not meet its stated purpose. +# All three are called out in the delivery changelog, not slipped in: +# (A) rabbitmq-server min-cluster-size: 3. Per D-009 AMENDMENT (2026-07-02): +# "rabbitmq scale-up is NOT mechanical without min-cluster-size" -- without +# it the charm accepts client relations BEFORE the cluster forms (same race +# as the D-062 mysql formation failure). Roosevelt/VR1 delta = set it +# alongside num_units: 3. rabbitmq clusters natively (quorum), NO hacluster. +# (B) hacluster cluster_count: 3 (all 12 active hacluster subs + the re-added +# vault-hacluster). The base bundle pins cluster_count: 1 -- explicitly +# "decorative on single-unit testcloud" (D-009 / BUNDLEFIX-003). At 3 units +# with cluster_count still 1, pacemaker can bootstrap the VIP off a single +# node = the SAME decorative behavior D-121 exists to eliminate. 3 makes +# the charm wait for a real 3-node quorum before configuring resources. +# VERIFY at deploy (not fetched this session): confirm the hacluster +# 2.4/stable charm treats cluster_count as "peers required before the +# cluster forms / VIP binds" -- the base bundle explicitly pinned it to 1 +# as decorative, and real 3-node quorum needs 3 regardless of the charm +# default. Flag if `juju config hacluster` disagrees. +# (C) vault-hacluster is DEFINED here, not just related. D-121's prose says it +# "exists among the hacluster subs" -- it does NOT: bundle.yaml line ~923 +# has it COMMENTED OUT (removed by BUNDLEFIX-002 when vault was de-HA'd, +# C1). So the re-added `[vault:ha, vault-hacluster:ha]` relation would +# reference a non-existent app (provider-bundle-check rule 4 would FAIL). +# This overlay re-declares it, matching the other haclusters' shape. +# +# VAULT BACKEND (D-121 sub-ruling = v-a, RESOLVED 2026-07-15): vault stays +# MYSQL-backed (on the already-3-unit mysql-innodb-cluster via vault-mysql-router, +# unchanged) and scales to 3 -- NO etcd, NO Raft here. GATE (Stage 5, before +# commit): verify the deployed vault 1.8/stable charm actually does HA +# leader-election on the MySQL storage backend at 3 units. If it does NOT: do +# NOT fall back to etcd -- evaluate Raft integrated storage and route to D-068. +# 3-unit vault ALSO triples the manual-unseal burden (intersects OPEN SEC-003). +# +# NOT changed here (deliberately, per D-121): mysql-innodb-cluster / ovn-central +# / ceph-mon (already 3 in the base bundle); ceph-osd / nova-compute (scale-out, +# sized to node count -- Option C = ceph-osd 3, nova-compute 2, on the storage / +# compute nodes, NOT control-node containers); ceph-rbd-mirror stays 1 (D-108); +# memcached / designate-bind / glance-simplestreams-sync unchanged. +# ===================================================================== + +applications: + + # ---- Clustered API charms (hacluster + dual VIP already wired in base) ---- + # Each: 1 -> 3 units, one per control node (positional anti-affinity). + keystone: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + glance: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + neutron-api: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + nova-cloud-controller: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + placement: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + cinder: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + openstack-dashboard: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + octavia: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + barbican: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + magnum: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + designate: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + ceph-radosgw: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + # ---- RabbitMQ: native quorum clustering, NO hacluster. min-cluster-size is + # MANDATORY at multi-unit (D-009 AMENDMENT 2026-07-02, item A above). ---- + rabbitmq-server: + num_units: 3 + options: + min-cluster-size: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + # ---- Vault: MySQL-backed (v-a), scale to 3, re-HA'd via vault-hacluster + # (defined below) + the relation re-added at the foot of this file. ---- + vault: + num_units: 3 + to: ["lxd:{{VR1_DC0_CONTROL_01}}", "lxd:{{VR1_DC0_CONTROL_02}}", "lxd:{{VR1_DC0_CONTROL_03}}"] + + # ---- vault-hacluster: RE-DECLARED (item C above). Removed by BUNDLEFIX-002 + # when vault was de-HA'd; D-121 (v-a) brings it back. Shape matches the + # other hacluster subs in bundle.yaml, with cluster_count: 3 (real HA). ---- + vault-hacluster: {charm: hacluster, channel: 2.4/stable, options: {cluster_count: 3}, bindings: {'': metal-admin, ha: metal-internal, hanode: metal-internal, pacemaker-remote: metal-internal, peer-availability: metal-internal}} + + # ---- cluster_count: 1 -> 3 for the 12 active hacluster subs (item B above). + # Overlay apply deep-merges options per-key -- the SAME mechanism + # dc-dc-ipv6-family-matrix.yaml already relies on (it supplies only + # `options:` for keystone et al. and depends on charm/channel/bindings + # surviving from base). So ONLY cluster_count is overridden here; each + # sub's base bindings/charm/channel are preserved. ---- + keystone-hacluster: {options: {cluster_count: 3}} + glance-hacluster: {options: {cluster_count: 3}} + neutron-api-hacluster: {options: {cluster_count: 3}} + nova-cloud-controller-hacluster: {options: {cluster_count: 3}} + placement-hacluster: {options: {cluster_count: 3}} + openstack-dashboard-hacluster: {options: {cluster_count: 3}} + cinder-hacluster: {options: {cluster_count: 3}} + octavia-hacluster: {options: {cluster_count: 3}} + barbican-hacluster: {options: {cluster_count: 3}} + magnum-hacluster: {options: {cluster_count: 3}} + ceph-radosgw-hacluster: {options: {cluster_count: 3}} + designate-hacluster: {options: {cluster_count: 3}} + +relations: + # ---- Re-add vault HA (removed by BUNDLEFIX-002; D-121 v-a re-adds it). + # vault already carries the `ha: metal-internal` binding in the base + # bundle, and vault-hacluster is declared above. ---- + - [vault:ha, vault-hacluster:ha] diff --git a/runbooks/dc-dc-phase2-tofu-dc-substrate.md b/runbooks/dc-dc-phase2-tofu-dc-substrate.md index a57aa64..fa61feb 100644 --- a/runbooks/dc-dc-phase2-tofu-dc-substrate.md +++ b/runbooks/dc-dc-phase2-tofu-dc-substrate.md @@ -3,10 +3,26 @@ From Office1, script each DC's "physical" layer into existence: the node-VM libvirt domains MAAS will discover and deploy, the DC's plane networks, and its OPNsense edge -- then register the DC's libvirt host to that DC's own -MAAS rack controller as a virsh VM-host. This runbook is written **DC1-first**. -DC2 is explicitly gated (see the callout below) and is NOT part of this -session's run until that gate clears -- do not duplicate these steps for DC2 -with invented values in the meantime. +MAAS rack controller as a virsh VM-host. This runbook builds **`vr1-dc0`** +(VR1's FIRST DC -- the build target this pass). **`vr1-dc1`** (VR1's SECOND +DC) is explicitly gated (see the callout below) and is NOT part of this +session's run until that gate clears -- do not duplicate these steps for +`vr1-dc1` with invented values in the meantime. + +!!! **NAMING -- this runbook was written pre-D-119 and has since been + reconciled to the region-qualified namespace.** Its original prose spoke + the bare `dcN` namespace (`dc1` = the build target, `dc2` = the second + DC). **D-119 REJECTS bare `dcN` for VR1 datacenters** -- the token `dc0` + already means VR0's live DC in `scripts/lib-net.sh`, so an unqualified + `dcN` is a latent off-by-one. The mapping applied throughout this file is: + runbook "DC1" -> **`vr1-dc0`** (already live in `opentofu/main.tf` as the + `vr1_dc0_*` modules), runbook "DC2" -> **`vr1-dc1`** (still gated). All + object names, module labels, plan files, and network/pool names below are + region-qualified to match `main.tf`. + **HAZARD:** following ANY residual bare-`dcN` literal in a copy of this + runbook (or in muscle memory) literally reintroduces the exact `dc0`/`dc1` + cross-cloud collision D-119 was written to delete -- region-qualify it or + stop. (D-119.) **Governing docs:** `docs/dc-dc-buildout-design.md` Section 4 "Phase 2" (the goal/build/gate this runbook satisfies), Section 9 (shim register -- the @@ -34,15 +50,23 @@ past that dependency. Confirm Stage 2's actual state in `docs/dc-dc-deployment-workflow.md` before starting. -!!! **DC2 is explicitly gated, not merely "later."** D-101 has not assigned - DC2's real, non-overlapping v4 supernet yet (open sub-item; Tooling gap - register item 3's DATA half -- the import pipeline mechanism - (`netbox/dc-dc-prefixes-import.py`) is built, but the literals themselves - are not assigned). `opentofu/main.tf`'s `dc2_planes`/`dc2_storage` blocks - are commented out for exactly this reason. Do NOT uncomment them, do NOT - invent a placeholder DC2 CIDR, and do NOT run this runbook's steps against - DC2 until NetBox has assigned the real supernet and `main.tf` reflects it - for real. This is a hard gate, re-stated in the GATE section. +!!! **`vr1-dc1` is explicitly gated -- by SEQUENCING, not by a missing + literal.** The earlier rationale ("NetBox has not assigned the supernet") + is STALE: **D-115 ALREADY assigned it** -- `vr1-dc1` gets `10.12.64.0/19` + (moved inside the Cloud `/16`, colliding with nothing `vr1-dc0` occupies), and + that carve is imported to `office1-netbox`. So the blocker is no longer a + missing address; `vr1-dc1` stays out of scope THIS pass purely because we + build one DC at a time and `vr1-dc0` is the target. `opentofu/main.tf`'s + `vr1_dc1_planes` block is still commented out (its `vr1_dc1_storage` and + the three mesh legs ARE wired, being address-independent). Do NOT + uncomment the planes block, do NOT add `vr1-dc1` build steps, and do NOT + run this runbook's steps against `vr1-dc1` this pass. Re-run the whole + runbook for `vr1-dc1` when it is sequenced. This is a hard gate, re-stated + in the GATE section. + NB: `main.tf`'s commented `vr1_dc1_planes` block still carries the OLD + "wait for NetBox to assign D-101's supernet" comment -- that comment is + now stale per D-115; flagged as a divergence to fix under the `main.tf` + owner, out of scope for this doc-only pass. !!! **Node-VM creation has NO Roosevelt analog (Section 9 shim register).** `modules/node-vm` (the blank-disk, PXE-boot libvirt domain this stage @@ -53,12 +77,15 @@ onward (commission/deploy, Juju bundle) IS Roosevelt-transferable; this one step is not. -!!! **Node sizing is not decided -- do not invent vCPU/RAM/disk.** - `modules/node-vm`'s `memory_mib`, `vcpu`, and `disk_size_bytes` variables - have no defaults by design (buildout-design Section 3's Phase-0 host/ - disk-budget decision has not been made). If that decision is still open - when you reach Step 6, STOP there and flag it -- do not pick numbers that - "seem reasonable" to get `tofu plan` to run. +!!! **Node sizing is now RULED (D-121, Option C, 2026-07-15).** + `modules/node-vm`'s `memory_mib`/`vcpu`/`disk_size_bytes` still have no + module defaults by design, but the VALUES to pass are no longer open: + D-121 ruled the per-DC layout as **3 control + 2 compute + 3 storage** (8 + node VMs) at the sizes wired into Step 6. This was the exact decision that + used to block Step 6; it is now UNBLOCKED. Still open (one sub-ruling + D-121 notes): the vault-HA backend fork -- but that is a Stage-5 bundle + concern, NOT a Step-6 blocker (node layout is fully ruled). Do not invent + any OTHER node spec; use D-121's numbers exactly. !!! **netem parameters are still an unruled D-100 sub-item (gap #11).** This stage's own gate text says "netem parameters applied and measured," but @@ -84,28 +111,34 @@ --- -## Sequence (DC1; DC2 remains gated -- see callout above) +## Sequence (`vr1-dc0`; `vr1-dc1` remains gated -- see callout above) + +Honest achievable scope for a first run is **Steps 1-8** (libvirt-layer +objects + the edge). Step 9 onward is gated on `vr1-dc0`'s own MAAS rack +controller existing -- a Stage-4 gap (per-DC rack-controller source is not yet +stood up); do not treat Steps 9-12 as runnable until it is. ``` 1. Pre-flight: confirm Stage 1 + Stage 2 gates closed (read-only) 2. Confirm OPNsense prep-tool prereqs on the vcloud host (read-only) -3. Confirm/record still-open decisions this stage depends - on (node sizing, MAAS zone/pool, netem parameters) - -- STOP on any that are unresolved (read-only) -4. Prepare DC1's OPNsense base image (render/config-ISO steps DELETED - 2026-07-13 -- config is done over the REST API) [MUTATION: host-local files, gated] -5. Wire modules/opnsense-edge for DC1 into main.tf [repo change, gated] -6. Wire modules/node-vm calls for DC1's node VMs into - main.tf (ONLY once sizing is real) [repo change, gated] +3. Confirm/record decisions this stage depends on (node + sizing = D-121 RESOLVED; edge/WAN/LAN = D-122 RESOLVED; + MAAS zone/pool; netem parameters) -- STOP on any open (read-only) +4. Prepare vr1-dc0's OPNsense base image (prep only; edge + config is done over the REST API -- D-113(a2)) [MUTATION: host-local files, gated] +5. Wire modules/opnsense-edge for vr1-dc0 into main.tf [repo change, gated] + (prereq: build the vr1-dc0-wan uplink, office1-wan shape) +6. Wire the 8 modules/node-vm calls for vr1-dc0's node VMs + into main.tf (D-121 Option C sizes) [repo change, gated] 7. tofu init / validate / plan -- review before apply (read-only) -8. tofu apply -- DC1 OPNsense edge + DC1 node-VM domains [MUTATION: creates libvirt objects, gated] -9. Wire + apply modules/maas-vm-host -- register DC1's - libvirt host with DC1's MAAS rack controller [MUTATION: MAAS + repo change, gated] +8. tofu apply -- vr1-dc0 OPNsense edge + node-VM domains [MUTATION: creates libvirt objects, gated] +9. Wire + apply modules/maas-vm-host -- register vr1-dc0's + libvirt host with vr1-dc0's MAAS rack controller [MUTATION: MAAS + repo change, gated] 10. Verify MAAS discovers the node VMs (read-only) -11. Wire + apply modules/netem-link on DC1's mesh legs, +11. Wire + apply modules/netem-link on vr1-dc0's mesh legs, using the Section-6 placeholder lean, explicitly flagged [MUTATION: gated] 12. Verify edge routing + simulated ISP uplink + netem applied (read-only) - -> EXIT GATE -> Stage 4 (per-DC, once DC2 clears its own gate) + -> EXIT GATE -> Stage 4 (per-DC, once vr1-dc1 clears its own gate) ``` --- @@ -117,9 +150,10 @@ virsh net-list --all virsh pool-list --all ``` -Expect: DC1's six plane networks, the three mesh-link networks (`dc1-dc2`, -`dc1-office1`, `dc2-office1`), and the `dc1`/`office1` storage pools, all -`active` -- Stage 1's Step 11 verification. If any are missing, Stage 1 is +Expect: `vr1-dc0`'s six plane networks, the three mesh-link networks +(`vr1-dc0-vr1-dc1`, `vr1-dc0-office1`, `vr1-dc1-office1` -- the region- +qualified names now in `main.tf`), and the `vr1-dc0`/`office1` storage pools, +all `active` -- Stage 1's Step 11 verification. If any are missing, Stage 1 is not actually done regardless of what `docs/dc-dc-deployment-workflow.md` says -- stop and reconcile there first. @@ -166,35 +200,47 @@ ## Step 3 -- Confirm/record still-open decisions this stage depends on (READ-ONLY) -Before wiring anything into `main.tf`, walk this list. Any "NOT YET" item is a -STOP for the corresponding later step -- record it plainly rather than -inventing a value to keep moving: +Before wiring anything into `main.tf`, walk this list. Two of the items that +used to block this stage are now RULED (D-121, D-122) and are recorded as +RESOLVED below; the genuinely-open ones remain STOPs. Record each plainly +rather than inventing a value to keep moving: -- **Node sizing** (`memory_mib`/`vcpu`/`disk_size_bytes` for `modules/node-vm`, - and how many node VMs DC1 needs) -- buildout-design Section 3's Phase-0 - host/disk-budget decision. Blocks Step 6. -- **MAAS zone/pool for DC1's VM-host registration** (`modules/maas-vm-host`'s - `zone`/`pool` variables) -- both are optional (MAAS computes them if left - `null`, confirmed safe per `opentofu/README.md`'s audit pass), so this is - NOT necessarily a blocker; record whichever real zone/pool name the - operator wants, or confirm the `null` no-op is intentional. Affects Step 9. -- **DC1's real `power_address`** (the virsh URI MAAS uses to reach the vcloud - host) -- must be measured this session (or carried from Stage 1's +- **Node sizing** (`memory_mib`/`vcpu`/`disk_size_bytes` and count for + `modules/node-vm`) -- **RESOLVED (D-121, Option C, 2026-07-15).** Per DC: 3 + control @ 16 vCPU / 65536 MiB / 150 GiB; 2 compute @ 12 vCPU / 49152 MiB / + 100 GiB; 3 storage @ 8 vCPU / 24576 MiB / 550 GiB -- 8 node VMs total. Step + 6 wires exactly these. Open sub-ruling D-121 still carries (the vault-HA + backend fork) does NOT block Step 6 -- it is a Stage-5 bundle concern. +- **Edge sizing / WAN uplink / LAN role** -- **RESOLVED (D-122, 2026-07-15).** + The DC edge follows the proven Office1 pattern: `memory_mib = 2048`, + `vcpu = 2`, nano base image (NO `disk_size_bytes`, like `office1_opnsense`). + The edge is **2-NIC** (baremetal-matched): `wan_network_name` is a + **dedicated per-site ISP uplink `vr1-dc0-wan`** (`172.30.2.0/24`, D-115) built + on the `office1-wan` pattern -- NOT a mesh leg (dark fiber is East-West only). + `lan_network_name` is **provider-public** -- the six planes are routed by the + fabric (OVN/OpenStack on isolated-L2 segments), so the edge is only the + external gateway on provider-public (D-100), not an inter-plane router. See + Step 5. PREREQUISITES: build `vr1-dc0-wan` (as a tofu module) and register + `172.30.2.0/24` in office1-netbox (not yet loaded). +- **MAAS zone/pool for `vr1-dc0`'s VM-host registration** + (`modules/maas-vm-host`'s `zone`/`pool` variables) -- both are optional + (MAAS computes them if left `null`, confirmed safe per `opentofu/README.md`'s + audit pass), so this is NOT necessarily a blocker; record whichever real + zone/pool name the operator wants, or confirm the `null` no-op is + intentional. Affects Step 9. +- **`vr1-dc0`'s real `power_address`** (the virsh URI MAAS uses to reach the + vcloud host) -- must be measured this session (or carried from Stage 1's `libvirt_uri` tfvar, if it is the SAME endpoint -- confirm, do not assume identical per `modules/maas-vm-host`'s own variable note). Blocks Step 9. -- **OPNsense `config.xml` tokens** -- per `opentofu/templates/README.md`'s - token legend, several tokens are pending Stage-0-adjacent decisions or a - real boot measurement: `{{WAN_IF}}`/`{{LAN_IF}}` (only measurable by - booting the domain and running `ifconfig` -- genuinely cannot be filled in - before Step 8 creates the domain), `{{WAN_IPADDR}}`/`{{WAN_SUBNET_BITS}}`/ - `{{WAN_GATEWAY}}` (pending NetBox), `{{LAN_IPADDR}}`/`{{LAN_SUBNET_BITS}}` - (pending which plane(s) OPNsense's LAN interface actually serves -- - flagged in the template README as not fully specified in the buildout - design), `{{MIRROR_SYNC_PROTOCOL}}`/`{{MIRROR_UPSTREAM_NET}}`/ - `{{MIRROR_SYNC_PORT}}` (pending mirror-software choice, gap #3-adjacent), - and `{{ROOT_PASSWORD_HASH}}` (must be freshly generated per site, never - reused from the shipped sample). Blocks Step 4's render sub-step for - everything except `{{OPNSENSE_HOSTNAME}}`/`{{DOMAIN}}`/NTP defaults. +- **`vr1-dc0`'s MAAS rack controller** -- a Stage-4 gap: the per-DC + rack-controller source is not yet stood up. This is the reason honest Step-9 + scope is BLOCKED and this pass's real reach is Steps 1-8 (see the GATE + section). Blocks Steps 9-12. +- **Edge post-boot interface mapping** (formerly the `{{WAN_IF}}`/`{{LAN_IF}}` + `config.xml` tokens) -- NO LONGER a config-render blocker. There is no + `config.xml` render (D-112/D-113(a2)); the real `vtnetN` mapping is measured + AFTER boot over SSH (`ifconfig`), then applied over the REST API. See the + REPLACEMENT chain in Step 4. Nothing here blocks wiring Step 5. - **netem parameters** -- still an unruled D-100 sub-item (gap #11); Step 11 uses the Section-6 qualitative placeholder only, explicitly labeled as such. Not a hard blocker for Step 11 (the mechanism can be exercised with a @@ -208,45 +254,43 @@ --- -## Step 4 -- Prepare DC1's OPNsense base image + config.xml + config ISO [MUTATION: host-local files, gated] +## Step 4 -- Prepare `vr1-dc0`'s OPNsense base image [MUTATION: host-local files, gated] -> ## !!! STOP -- THIS STEP IS OBSOLETE AS WRITTEN (2026-07-13) !!! +> ## THE EDGE-CONFIG PATH (settled 2026-07-13 -- this IS the path) > -> Nothing here clobbers a live system (DC1's edge does not exist yet), but **two of the three -> sub-steps below cannot achieve what they claim**, and following them will waste a session and -> produce a dead edge: +> This step is now **prep the base image only.** Edge configuration is NOT baked into an image +> or seeded from an ISO -- it is done over the **REST API after boot**, the path proven end to +> end on the live Office1 edge (2026-07-12/13). The two deleted approaches, and why: > -> 1. **The config ISO is INERT -- it can never be read.** Per **D-112** (root-caused from +> 1. **The config ISO was INERT -- it could never be read.** Per **D-112** (root-caused from > upstream source): `opnsense-importer -b` probes for a read-only root. On INSTALLER media the > probe fails and the importer scans attached media. On our PRE-INSTALLED NANO image the root > is writable AND a factory `/conf/config.xml` already exists, so BOTH conditions hold and it > `bootstrap_and_exit 0`s **without enumerating a single device**. The Configuration Importer -> can NEVER fire on a nano image, by design. Building the ISO is pure waste. +> can NEVER fire on a nano image, by design -- so the ISO builder is DELETED and the module no +> longer takes a `config_iso_path`. > 2. **Full-`config.xml` rendering is SUPERSEDED** by **D-113(a2)** (ruled 2026-07-13): edge -> config is done over the **REST API** (`scripts/opnsense-api.sh`), which was proven end to -> end -- read AND write -- against the live Office1 edge on 2026-07-13. Hand-authoring the -> appliance's GUI-owned XML caused DOCFIX-191 (management lockout), DOCFIX-192 (dead console) -> and DOCFIX-193 (no DHCP, and an ISC `<dhcpd>` block that would have been inert against a Kea -> backend). None of those bugs is expressible through the API. +> config is done over the **REST API** (`scripts/opnsense-api.sh`), proven end to end -- read +> AND write -- against the live Office1 edge on 2026-07-13. Hand-authoring the appliance's +> GUI-owned XML caused DOCFIX-191 (management lockout), DOCFIX-192 (dead console) and +> DOCFIX-193 (no DHCP, and an ISC `<dhcpd>` block that would have been inert against a Kea +> backend). None of those bugs is expressible through the API. The renderer is DELETED. > -> **The `WAN_IF`/`LAN_IF` chicken-and-egg problem discussed below is therefore MOOT** -- it was an -> artifact of trying to seed a full config before first boot. The D-112(c) bootstrap (boot on -> factory defaults -> reach the console -> enable SSH + install the key -> configure over the -> network) measures the real `vtnetN` mapping *after* boot, where it is knowable. +> **There is no `WAN_IF`/`LAN_IF` chicken-and-egg.** It only ever existed because we tried to seed +> a full config before first boot. The D-112(c) bootstrap (boot on factory defaults -> reach the +> console -> enable SSH + install the key -> configure over the network) measures the real +> `vtnetN` mapping *after* boot, where it is knowable -- nothing to guess before Step 8. > -> **What DC1's edge should actually do (the proven Office1 path):** prep the image -> boot it -> -> D-112(c) console bootstrap -> configure over SSH + the REST API. See +> **What `vr1-dc0`'s edge does (the proven Office1 path):** prep the image (this step) -> Step 5 +> wires the domain -> Step 8 boots it -> D-112(c) console bootstrap -> configure over SSH + the +> REST API (the REPLACEMENT chain below). See > `docs/changelog-20260712-office1-opnsense-edge-build.md`, > `docs/changelog-20260713-opnsense-api-proven.md`, and > `docs/changelog-20260713-opnsense-api-write-proven.md`. -> -> **This step is NOT yet rewritten.** Reducing the template to a minimal bootstrap and rewriting -> Stage 3's edge steps around the API is the open D-113(a2) work. Until that lands, STOP here and -> re-derive rather than following the sub-steps below. -This step produces plain files on the vcloud host filesystem -- no libvirt or -MAAS object is created yet. Run from the vcloud host (or wherever these -scripts execute with reach to write there). +This step produces one plain file on the vcloud host filesystem -- the prepped +base image -- no libvirt or MAAS object is created yet. Run from the vcloud +host (or wherever this script executes with reach to write there). **MUTATION -- prep the base image (needs Step 2's tools)** ```bash @@ -254,31 +298,14 @@ ``` **Expect:** a decompressed, qcow2-converted, resized OPNsense nano image at a real output path -- record that path, it feeds Step 5's `base_volume_path`. - -**(DELETED 2026-07-13.)** The `config.xml` render step is GONE -- the renderer -(`opnsense-render-config.sh`, formerly under scripts/) no longer exists. Edge config is done over the REST API -(D-113(a2)). See the replacement chain at the end of this step. -`WAN_IF`/`LAN_IF` are the one pair the script's own `REQUIRED_VARS` list -demands that genuinely cannot be measured before the domain exists (Step 8 -creates it). If Step 3 flagged these as not yet measurable, this render -sub-step cannot complete for real this session -- STOP here for DC1's config -render, record it as blocked pending a real boot, and do not substitute a -guessed `vtnetN` name. (One documented path forward once the domain exists: -boot the domain with a MINIMAL/placeholder config good enough to reach a -console, measure `ifconfig`'s real `vtnetN` mapping, then re-render and -re-seed -- that re-seed mechanic is not designed in this delivery; treat it -as a real follow-up need if it comes to that, not an invented shortcut here.) - -**(DELETED 2026-07-13.)** The config-ISO step is GONE -- the ISO builder -(`opnsense-build-config-iso.sh`, formerly under scripts/) no longer exists, and the module no longer takes a -`config_iso_path`. The ISO was never read by anything (D-112: the Importer cannot fire on a -pre-installed nano image). +That single path is this step's ONLY output. There is no config.xml render and +no config-ISO build -- both are deleted (D-112 / D-113(a2), see the box above). --- ### The REPLACEMENT chain (proven on Office1, 2026-07-12/13) -No `config.xml` anywhere. After Step 5 creates the domain and it boots on FACTORY DEFAULTS: +No `config.xml` anywhere. After Step 5 wires the domain and Step 8 boots it on FACTORY DEFAULTS: 1. **Console bootstrap (D-112(c))** -- reach the serial console, enable SSH, install the service public key. This is the only step that needs the console; everything after it is scripted. @@ -295,74 +322,128 @@ bash scripts/opnsense-api.sh POST kea/service/reconfigure '{}' ``` -**`WAN_IF`/`LAN_IF` are measured AFTER boot** (`ifconfig` over SSH), where they are knowable -- -which is why the chicken-and-egg problem this step used to agonize over simply does not exist -anymore. +**The real `vtnetN` interface mapping is measured AFTER boot** (`ifconfig` over SSH), where it is +knowable -- there is no interface value to guess before the domain exists. -**GATE:** base image path and (if unblocked) config ISO path recorded as real -host-local file paths. If the config.xml render step is blocked on -`WAN_IF`/`LAN_IF`, record that explicitly and treat Step 5's `config_iso_path` -input as still pending -- do not wire Step 5 with a placeholder ISO. +**GATE:** the prepped base image path recorded as a real host-local file path +(it feeds Step 5's `base_volume_path`). Nothing else is produced or pending +here -- no config-ISO path exists to record. --- -## Step 5 -- Wire `modules/opnsense-edge` for DC1 into `main.tf` [repo change, gated] +## Step 5 -- Wire `modules/opnsense-edge` for `vr1-dc0` into `main.tf` [repo change, gated] -Only proceed once Step 4 produced real `base_volume_path` and -`config_iso_path` values (or an explicit decision to proceed with a -genuinely-final, if minimal, config.xml -- not a placeholder). +Proceed once Step 4 produced a real `base_volume_path`. The edge SHAPE is +ruled (D-122): it mirrors the already-applied `office1_opnsense` block in +`main.tf` -- `memory_mib = 2048`, `vcpu = 2`, nano image, **no +`disk_size_bytes`** (the disk is a direct copy of the prepped nano; the module +does not accept a size input -- DOCFIX-189), and **no `config_iso_path`** (the +module dropped it -- D-112). There is nothing to invent for the edge specs +themselves; the two values that ARE still per-site (the base image path, and +the two network names) are called out below. -**MUTATION -- edit `opentofu/main.tf`**, adding a block shaped like: +**PREREQUISITE -- build the `vr1-dc0-wan` uplink first.** D-122 gives the DC +its OWN dedicated L3 "ISP" uplink, NOT a mesh leg (dark fiber is East-West +replication only). Build a per-site NAT'd `/24` uplink network `vr1-dc0-wan` +on the **`office1-wan` pattern** (the same mechanical shape `office1-wan` was +created in -- a documented D-103 debt to formalize into a module later; today +it is a literal network name the edge's `wan_network_name` points at). This is +a prerequisite to wiring the block below with a real `wan_network_name`. + +**LAN side -- RESOLVED (D-122, operator ruling 2026-07-15): `provider-public`.** +The edge is **2-NIC** (WAN + LAN), matching the baremetal target: the six +planes are **routed by the fabric** (the `dc-planes` segments are isolated L2; +OVN/OpenStack own their L3, not the edge), so the edge is NOT an inter-plane +router -- only the external boundary. Its LAN attaches to **provider-public** +(`10.12.4.0/22`), where the edge is the upstream/external gateway the provider +network exits through (FIP/SNAT egress + GUA injection, D-100 br-ex/provider +model). metal-admin and the other planes reach external via fabric routing, not +a direct edge leg. So `lan_network_name` = the `vr1_dc0_planes` provider-public +output (confirm the exact output name in `modules/dc-planes`). No new +site-internal network module is needed (this SUPERSEDES the earlier "build an +office1-network analog" flag). + +**MUTATION -- edit `opentofu/main.tf`**, adding a block shaped like +`office1_opnsense` (main.tf, the applied reference): ```hcl -module "dc1_opnsense" { +module "vr1_dc0_opnsense" { source = "./modules/opnsense-edge" - vm_name = "dc1-opnsense" - memory_mib = <REAL VALUE -- OPNsense's own sizing guidance, not invented> - vcpu = <REAL VALUE> - pool_name = module.dc1_storage.pool_name # confirm real output name in modules/dc-storage-pool - disk_size_bytes = <REAL VALUE> - base_volume_path = "<Step 4's real base image path>" - config_iso_path = "<Step 4's real config ISO path>" - lan_network_name = <the dc1_planes output this edge's LAN side attaches to -- confirm which plane per Step 3's open LAN-role item> - wan_network_name = module.mesh_dc1_office1... # or a dedicated per-site ISP-uplink segment -- confirm which, do not assume + vm_name = "vr1-dc0-opnsense" + memory_mib = 2048 # D-122: Office1 pattern + vcpu = 2 # D-122: Office1 pattern + pool_name = module.vr1_dc0_storage.pool_name + # No disk_size_bytes: nano direct-copy, size from opnsense-prep-image.sh's GROW (DOCFIX-189). + base_volume_path = "<Step 4's real prepped-image path>" + lan_network_name = <the vr1_dc0_planes provider-public output -- D-122: edge is the external gateway on provider-public; confirm the output name in modules/dc-planes> + wan_network_name = "vr1-dc0-wan" # D-122: dedicated per-site ISP uplink, office1-wan pattern (build first, promote to a tofu module) } ``` -Every placeholder above (`<...>`) must be a real, measured/decided value -before this is committed -- this template line exists to show shape, not to -be copy-pasted with invented numbers. `memory_mib`/`vcpu`/`disk_size_bytes` -here have no ratified default in the module either (same "no invented specs" -discipline as `modules/node-vm`). +Before commit: the base image path (Step 4) must be real, `vr1-dc0-wan` must be +built (as a tofu module -- D-122) and `172.30.2.0/24` registered in office1-netbox +(D-115, not yet loaded), and `lan_network_name` bound to the real provider-public +plane output. `memory_mib`/`vcpu`/the nano-no-disk shape and the 2-NIC WAN/LAN +model are ruled (D-122), not invented. -**GATE:** `opentofu/main.tf` diff shows exactly one new `module "dc1_opnsense"` -block, every argument a real value, nothing else changed. +**GATE:** `opentofu/main.tf` diff shows exactly one new `module "vr1_dc0_opnsense"` +block, no `disk_size_bytes` and no `config_iso_path` line, every argument a +real value (with `vr1-dc0-wan` and the site-internal LAN network genuinely +built), nothing else changed. --- -## Step 6 -- Wire `modules/node-vm` calls for DC1's node VMs into `main.tf` [repo change, gated] +## Step 6 -- Wire `modules/node-vm` calls for `vr1-dc0`'s node VMs into `main.tf` [repo change, gated] -**ONLY if Step 3 confirmed node sizing is real.** If sizing is still "NOT -YET," STOP here -- this step does not proceed, and this session's DC1 pass -ends at whatever the last unblocked step was (Step 5 or wherever Step 4 left -off). Re-run from here once sizing exists; do not invent numbers to close -this out early. +**Node sizing is RULED (D-121, Option C).** Wire **8** `module "vr1_dc0_node_*"` +blocks in three role-sizes: -**MUTATION -- edit `opentofu/main.tf`**, one `module "dc1_node_XX"` block per -node VM, shaped like: +| Role | Count | vCPU | `memory_mib` | disk | Names | +|---------|-------|------|--------------|-------------|------------------------------------| +| control | 3 | 16 | 65536 | 150 GiB | `vr1-dc0-control-01..03` | +| compute | 2 | 12 | 49152 | 100 GiB | `vr1-dc0-compute-01..02` | +| storage | 3 | 8 | 24576 | 550 GiB | `vr1-dc0-storage-01..03` | + +`disk_size_bytes` is BYTES: express each as GiB x 1024^3 in HCL -- +`150 * 1024 * 1024 * 1024`, `100 * 1024 * 1024 * 1024`, +`550 * 1024 * 1024 * 1024` (the module's `disk_size_bytes` expects bytes; the +GROW/qcow2 layer is thin-provisioned so this is a max, not a preallocation). + +**Why 3 control (context):** this layout exists to carry the Stage-5 HA +scale-up (D-121). The bundle's **14 currently-single-unit services scale +1 -> 3**: the 11 hacluster+VIP API apps (keystone, glance, neutron-api, +nova-cloud-controller, placement, cinder, openstack-dashboard, octavia, +barbican, magnum, designate) plus `ceph-radosgw`, `rabbitmq-server`, and +`vault`. Those 3-unit control-plane services LIVE on the 3 control nodes -- +alongside the services that are ALREADY at 3 and do not change +(`mysql-innodb-cluster`, `ovn-central`, `ceph-mon`). Meanwhile `ceph-osd` = 3 +units on the 3 storage nodes and `nova-compute` = 2 units on the 2 compute +nodes. The node-COUNT/sizing here is fully ruled; the still-open D-121 +sub-item (the vault-HA backend fork) is a Stage-5 bundle concern and does NOT +gate this step. + +**MUTATION -- edit `opentofu/main.tf`**, one block per node VM (control shown; +compute/storage identical shape with their row's vCPU/memory/disk/name): ```hcl -module "dc1_node_01" { +module "vr1_dc0_control_01" { source = "./modules/node-vm" - vm_name = "dc1-node-01" - memory_mib = <REAL VALUE from the Phase-0 sizing decision> - vcpu = <REAL VALUE> - pool_name = module.dc1_storage.pool_name - disk_size_bytes = <REAL VALUE> - network_names = [ <ordered list of module.dc1_planes outputs -- confirm which plane goes first for PXE priority, per D-103> ] + vm_name = "vr1-dc0-control-01" + memory_mib = 65536 # D-121: control + vcpu = 16 # D-121: control + pool_name = module.vr1_dc0_storage.pool_name + disk_size_bytes = 150 * 1024 * 1024 * 1024 # D-121: control 150 GiB + network_names = [ <ordered list of module.vr1_dc0_planes outputs -- confirm which plane goes first for PXE priority, per D-103> ] } ``` -Repeat per node, per the real node count from the sizing decision. +Repeat for `vr1_dc0_control_02`/`03`, `vr1_dc0_compute_01`/`02` (12 vCPU / +49152 MiB / `100 * 1024 * 1024 * 1024`), and `vr1_dc0_storage_01`/`02`/`03` +(8 vCPU / 24576 MiB / `550 * 1024 * 1024 * 1024`). **`network_names` is STILL a +flagged STOP** in every block: D-121 ruled the SIZES, not the plane +attachment/PXE order -- that ordered plane list is an unresolved value (D-103 +PXE priority) and must not be invented to make the block parse. -**GATE:** every `module "dc1_node_*"` block uses only real, decided values; -node count matches the recorded sizing decision exactly. +**GATE:** exactly 8 `module "vr1_dc0_node_*"`-shaped blocks (3 control / 2 +compute / 3 storage) with D-121's sizes verbatim; each `network_names` either +a genuinely-resolved plane list or explicitly recorded as the remaining STOP, +never a guessed order. --- @@ -373,24 +454,25 @@ tofu fmt -check -recursive -diff . tofu init -backend=false -input=false tofu validate -tofu plan -out=phase2-dc1.tfplan +tofu plan -out=phase2-vr1-dc0.tfplan ``` -Review the plan line by line: expect creates for `module.dc1_opnsense` -(the edge VM's boot volume, config-seed volume, and domain) and each -`module.dc1_node_*` (a blank boot volume + domain per node). Confirm nothing -else is planned -- in particular, confirm no DC2 resource appears (its -module blocks should still be commented out per the DC2 gate above). +Review the plan line by line: expect creates for `module.vr1_dc0_opnsense` +(the edge VM's boot volume and domain -- NO config-seed volume/cdrom anymore; +that path is deleted, D-112) and each `module.vr1_dc0_*` node (a blank boot +volume + domain per node: 3 control, 2 compute, 3 storage). Confirm nothing +else is planned -- in particular, confirm no `vr1-dc1` resource appears (its +`vr1_dc1_planes` block should still be commented out per the gate above). **GATE:** plan matches this expectation exactly; no unexpected creates, updates, or destroys. Do not apply a plan you have not read. --- -## Step 8 -- `tofu apply` -- DC1 OPNsense edge + node-VM domains [MUTATION: gated] +## Step 8 -- `tofu apply` -- `vr1-dc0` OPNsense edge + node-VM domains [MUTATION: gated] ```bash cd opentofu -tofu apply phase2-dc1.tfplan +tofu apply phase2-vr1-dc0.tfplan ``` Confirm this is the exact reviewed plan file from Step 7 (not re-planned) before running. This is the first live mutation THIS stage performs. @@ -398,12 +480,13 @@ **VERIFY** ```bash virsh list --all -virsh domblklist dc1-opnsense -virsh domblklist dc1-node-01 # per real node name +virsh domblklist vr1-dc0-opnsense +virsh domblklist vr1-dc0-control-01 # per real node name ``` Expect the new domains present (likely `shut off` until first boot/PXE), the -OPNsense domain showing both the base overlay disk and the config ISO -attached as a secondary cdrom, each node VM showing a single blank boot disk. +OPNsense domain showing a SINGLE boot disk (a direct copy of the prepped nano +-- no config-ISO cdrom; that path is deleted, D-112), each node VM showing a +single blank boot disk. **GATE:** all new domains defined as planned; disk attachments match Step 5/6's wiring. @@ -418,20 +501,26 @@ --- -## Step 9 -- Wire + apply `modules/maas-vm-host` -- register DC1's libvirt host with DC1's MAAS rack controller [MUTATION: gated] +## Step 9 -- Wire + apply `modules/maas-vm-host` -- register `vr1-dc0`'s libvirt host with `vr1-dc0`'s MAAS rack controller [MUTATION: gated] -**Hard dependency on Stage 2.** This step requires DC1's MAAS rack controller -(under the Stage-2-stood-up MAAS region) to actually exist and be reachable. -If Stage 2 (or the DC1 rack-controller enrollment specifically, however that -ends up sequenced relative to Stage 2's own runbook) is not done, STOP here --- this stage's remaining steps (9-12) do not proceed, and DC1's Phase 2 work -is INCOMPLETE, not silently skippable. +**Hard dependency -- `vr1-dc0`'s MAAS controller.** This step requires +`vr1-dc0`'s own MAAS to exist and be reachable. **D-123 (PROPOSED, recommend +Model A) resolves what that is:** a `vvr1-dc0` **site headend VM** built the +proven Office1 way -- an OpenTofu `cloudinit-vm` block shaped like +`module "voffice1"` + a run of `scripts/site-headend-install.sh` (which already +names `vvr1-dc0/vvr1-dc1` as targets) to install MAAS region+rack + LXD. Under +Model A the node VMs stay vcloud-level and this MAAS DISCOVERS them via the +`maas-vm-host` registration below (power_address = vcloud's virsh). Until Model +A is ruled and `vvr1-dc0` is stood up, this is the honest scope boundary: +**Steps 1-8 are this pass's achievable scope; Steps 9-12 STOP here** -- +`vr1-dc0`'s Phase 2 is then INCOMPLETE, not silently skippable. (Also inherits +Stage 2's reachable-MAAS-region dependency.) **MUTATION -- edit `opentofu/main.tf`** ```hcl -module "dc1_maas_vm_host" { +module "vr1_dc0_maas_vm_host" { source = "./modules/maas-vm-host" - vm_host_name = "dc1-vcloud" + vm_host_name = "vr1-dc0-vcloud" power_address = "<Step 3's real, measured virsh URI -- confirm same as var.libvirt_uri or a distinct value>" zone = <real zone name, or leave unset for MAAS's computed default -- per Step 3> pool = <real pool name, or leave unset -- per Step 3> @@ -440,8 +529,8 @@ ```bash cd opentofu -tofu plan -out=phase2-dc1-maashost.tfplan # review: exactly one new maas_vm_host resource -tofu apply phase2-dc1-maashost.tfplan +tofu plan -out=phase2-vr1-dc0-maashost.tfplan # review: exactly one new maas_vm_host resource +tofu apply phase2-vr1-dc0-maashost.tfplan ``` **GATE:** `maas_vm_host` resource created; no other resource touched by this @@ -453,22 +542,22 @@ **CHECK** ```bash -maas admin vm-hosts read | jq -r '.[] | select(.name=="dc1-vcloud") | {id, name, resources}' -maas admin machines read | jq -r '.[] | select(.hostname|test("^dc1-node")) | "\(.hostname)\t\(.status_name)"' +maas admin vm-hosts read | jq -r '.[] | select(.name=="vr1-dc0-vcloud") | {id, name, resources}' +maas admin machines read | jq -r '.[] | select(.hostname|test("^vr1-dc0-")) | "\(.hostname)\t\(.status_name)"' ``` -Expect DC1's node VMs listed as MAAS machines (status likely `New` -- +Expect `vr1-dc0`'s node VMs listed as MAAS machines (status likely `New` -- enlistment discovered them; commissioning is Stage 4's job, not this stage's). If the node VMs do not appear, do not proceed to declare this stage's MAAS-discovery gate met -- troubleshoot the VM-host registration (power_address correctness, virsh reachability from the MAAS rack controller) before moving on. -**GATE:** DC1's OpenTofu-created node VMs visible in MAAS, associated with -the `dc1-vcloud` VM host. +**GATE:** `vr1-dc0`'s OpenTofu-created node VMs visible in MAAS, associated with +the `vr1-dc0-vcloud` VM host. --- -## Step 11 -- Wire + apply `modules/netem-link` on DC1's mesh legs, placeholder profile [MUTATION: gated] +## Step 11 -- Wire + apply `modules/netem-link` on `vr1-dc0`'s mesh legs, placeholder profile [MUTATION: gated] Per the netem callout above: this step applies ONLY the buildout design's qualitative Section 6 lean ("same-metro dark fiber... low single-digit ms, @@ -477,26 +566,29 @@ profile, use that instead and cite the ruling; do not use this placeholder language once a real ruling exists. -**MUTATION -- edit `opentofu/main.tf`**, one block per relevant mesh leg -(at minimum DC1<->DC2 and DC1<->Office1; DC2<->Office1 is out of scope while -DC2 remains gated): +**MUTATION -- edit `opentofu/main.tf`**, one block per relevant mesh leg. The +only leg with substrate on both ends this pass is `vr1-dc0`<->`office1` +(`vr1-dc0`<->`vr1-dc1` and `vr1-dc1`<->`office1` have no `vr1-dc1`-side +substrate while `vr1-dc1` remains gated): ```hcl -module "netem_dc1_office1" { +module "netem_vr1_dc0_office1" { source = "./modules/netem-link" - link_name = "dc1-office1" + link_name = "vr1-dc0-office1" vcloud_host_ssh_target = "<real SSH target for the vcloud host -- measured, not invented>" - bridge_name = "<real OS bridge name for this mesh-link network -- confirm via `virsh net-info dc1-office1` or `tofu show`, not assumed>" + bridge_name = "<real OS bridge name for this mesh-link network -- confirm via `virsh net-info vr1-dc0-office1` or `tofu show`, not assumed>" netem_args = "<PLACEHOLDER pending D-100 sub-item ruling -- record explicitly as provisional, e.g. a low single-digit-ms delay with modest jitter and negligible loss, cited to buildout-design Section 6, NOT presented as a measured or ruled value>" } ``` -(A DC1<->DC2 block follows the same shape once DC2 itself is ungated; until -then, applying netem on that specific leg has no DC2-side substrate to -matter for, so it is naturally deferred alongside the rest of DC2's work.) +(A `vr1-dc0`<->`vr1-dc1` block -- `module "netem_vr1_dc0_vr1_dc1"`, +`link_name "vr1-dc0-vr1-dc1"` -- follows the same shape once `vr1-dc1` itself +is ungated; until then, applying netem on that leg has no `vr1-dc1`-side +substrate to matter for, so it is naturally deferred alongside the rest of +`vr1-dc1`'s work.) ```bash cd opentofu -tofu plan -out=phase2-dc1-netem.tfplan -tofu apply phase2-dc1-netem.tfplan +tofu plan -out=phase2-vr1-dc0-netem.tfplan +tofu apply phase2-vr1-dc0-netem.tfplan ``` **GATE:** the `terraform_data`/`local-exec` resource applies without error; @@ -514,20 +606,21 @@ ``` Expect a `netem` qdisc present with the applied (placeholder) parameters. -**CHECK -- OPNsense edge reachability** (only meaningful once WAN_IF/LAN_IF -have been measured on a real boot per Step 4's note, and the domain has -actually been booted/configured) +**CHECK -- OPNsense edge reachability** (only meaningful once the domain has +been booted and configured over the REST API per Step 4's REPLACEMENT chain -- +the `vtnetN` mapping and WAN/LAN addressing are measured/applied AFTER boot) ```bash -virsh domstate dc1-opnsense +virsh domstate vr1-dc0-opnsense ``` -If the config.xml render was blocked in Step 4 (pending `WAN_IF`/`LAN_IF`), -this verification is necessarily partial this session -- record that -honestly rather than declaring edge routing verified when it is not. +If the domain has not yet been through the console + REST-API bootstrap (the +D-112(c) chain), this verification is necessarily partial this session -- +record that honestly rather than declaring edge routing verified when it is +not. **GATE (this step):** netem qdisc present and matching Step 11's recorded -placeholder profile; OPNsense domain running IF its config.xml was -successfully completed, otherwise recorded as blocked pending a real-boot -`vtnetN` measurement and re-seed. +placeholder profile; OPNsense domain running IF it has been through the +post-boot REST-API bootstrap, otherwise recorded as blocked pending that +bootstrap (console -> SSH -> API, per Step 4). --- @@ -538,18 +631,23 @@ VMs; edge routing + simulated ISP uplink up; netem parameters applied and measured." As of what this runbook can actually close this session: -- **MAAS rack controller per DC up:** achievable ONLY once Stage 2 (or - whatever runbook stands up DC1's specific rack controller) is done -- - Step 1/9 depend on it. Record actual state, do not assume it. +- **MAAS rack controller per DC up:** achievable ONLY once `vr1-dc0`'s own + MAAS rack controller is stood up -- a Stage-4 gap that is NOT yet done + (D-122 confirms each site runs its own controller; the SOURCE for it is the + open item). Steps 1/9 depend on it. This is why honest scope for a first run + is Steps 1-8. Record actual state, do not assume it. - **MAAS discovers the OpenTofu-created node VMs:** achievable through Step - 10, CONDITIONAL on node sizing being real (Step 3/6) and Stage 2's MAAS - region being reachable (Step 9's dependency). + 10, CONDITIONAL on the per-DC rack controller existing (Step 9's dependency) + and Stage 2's MAAS region being reachable. Node sizing itself is no longer a + condition -- it is RULED (D-121, Step 6). - **Edge routing + simulated ISP uplink up:** PARTIALLY achievable. The - OPNsense domain and its network attachments can be created (Step 8), but - full edge routing verification depends on `WAN_IF`/`LAN_IF` being measured - on a real boot (Step 4's documented blocker) and on the WAN/LAN addressing - tokens (pending NetBox) -- do not mark this sub-gate fully closed unless - those are genuinely resolved. + OPNsense domain and its network attachments can be created (Step 8), and the + edge shape/uplink are RULED (D-122: `vr1-dc0-wan` dedicated ISP uplink, + Office1-pattern edge), but full edge routing verification depends on the + post-boot REST-API bootstrap (Step 4's REPLACEMENT chain -- console -> SSH + -> API, where the real `vtnetN` mapping and WAN/LAN addressing are measured + and applied). Do not mark this sub-gate fully closed until that bootstrap + has actually run against the booted domain. - **netem parameters applied and measured:** applied, YES (Step 11), using an explicitly-labeled PLACEHOLDER same-metro-lean profile per buildout-design Section 6 -- MEASURED in the sense of "the qdisc is @@ -561,21 +659,25 @@ remaining gap named. **This stage's exit gate is therefore CONDITIONALLY MET at best** on any run -before (a) Stage 2 is complete, (b) node sizing is decided, (c) DC1's -OPNsense WAN/LAN addressing and `vtnetN` mapping are measured, and (d) D-100's -exact netem parameters are ruled. Update `docs/dc-dc-deployment-workflow.md`'s -Stage 3 row to reflect the REAL state after running this (e.g. "PARTIAL -- -libvirt objects created, MAAS registration blocked on Stage 2" or similar), -never to a blanket DONE unless every sub-condition above is genuinely true. +before (a) `vr1-dc0`'s per-DC MAAS rack controller is stood up (Stage-4 gap) +and Stage 2's MAAS region is reachable, (b) `vr1-dc0`'s OPNsense edge has been +through the post-boot REST-API bootstrap (`vtnetN` + WAN/LAN applied), and +(c) D-100's exact netem parameters are ruled. Node sizing (D-121) and the edge +shape/uplink (D-122) are NO LONGER open. Update +`docs/dc-dc-deployment-workflow.md`'s Stage 3 row to reflect the REAL state +after running this (e.g. "PARTIAL -- libvirt objects created, MAAS +registration blocked on the per-DC rack controller" or similar), never to a +blanket DONE unless every sub-condition above is genuinely true. -**DC2:** none of the above applies to DC2 until NetBox assigns its real -supernet (D-101 open item) and `main.tf`'s DC2 blocks are uncommented with -real values -- re-run this entire runbook for DC2 at that point, do not -attempt a partial DC2 pass now. +**`vr1-dc1`:** none of the above applies to `vr1-dc1` this pass. Its v4 +supernet IS assigned (D-115, `10.12.64.0/19`) -- the blocker is sequencing, +not a missing literal -- but its `vr1_dc1_planes` block stays commented out +and it stays out of scope. Re-run this entire runbook for `vr1-dc1` when it is +sequenced; do not attempt a partial `vr1-dc1` pass now. --> Proceed to Stage 4 (MAAS enlist/commission/deploy) for DC1 once this -gate's applicable sub-conditions are genuinely met; DC2 remains blocked at -Stage 3 until its own gate (NetBox supernet) clears. +-> Proceed to Stage 4 (MAAS enlist/commission/deploy) for `vr1-dc0` once this +gate's applicable sub-conditions are genuinely met; `vr1-dc1` remains blocked +at Stage 3 by sequencing until it is taken up as its own pass. --- @@ -593,16 +695,19 @@ netem placeholder profile and its provisional status) -- redact nothing that isn't a secret. - [ ] `docs/session-ledger.md` updated with the outcome, including which - sub-steps were blocked and why (Stage 2 dependency, node sizing, - WAN/LAN token gaps, D-100 netem sub-item). + sub-steps were blocked and why (per-DC MAAS rack-controller Stage-4 gap, + Stage 2 MAAS-region dependency, the edge post-boot bootstrap, D-100 + netem sub-item). - [ ] `docs/dc-dc-deployment-workflow.md` Stage 3 row updated to the REAL, honest state (PARTIAL/DONE/NOT STARTED per the GATE section above), never rounded up to DONE with open sub-conditions. -- [ ] `opentofu/README.md` updated to reflect DC1's OPNsense edge / node-VM / - MAAS-vm-host / netem-link modules moving from "not instantiated" to - instantiated (or partially so) once this runbook actually applies - them, including any real bug found while doing so (matching this - repo's practice of logging schema surprises as they're found). -- [ ] DC2 gate re-confirmed as still closed (NetBox supernet not yet - assigned) if this session did not clear it -- do not let this - runbook's DC1 completion be misread as covering DC2. +- [ ] `opentofu/README.md` updated to reflect `vr1-dc0`'s OPNsense edge / + node-VM / MAAS-vm-host / netem-link modules moving from "not + instantiated" to instantiated (or partially so) once this runbook + actually applies them, including any real bug found while doing so + (matching this repo's practice of logging schema surprises as they're + found). +- [ ] `vr1-dc1` gate re-confirmed as still closed BY SEQUENCING (its supernet + IS assigned -- D-115 `10.12.64.0/19`) if this session did not take it + up -- do not let this runbook's `vr1-dc0` completion be misread as + covering `vr1-dc1`. diff --git a/scripts/site-headend-install.sh b/scripts/site-headend-install.sh index 46e7dd7..979503c 100644 --- a/scripts/site-headend-install.sh +++ b/scripts/site-headend-install.sh @@ -1,19 +1,39 @@ #!/usr/bin/env bash -# scripts/site-headend-install.sh [--check|--dry-run] --compose-cidr <CIDR> [--maas-url-ip <IP>] +# scripts/site-headend-install.sh [--check|--dry-run] [--role <region+rack|rack>] +# region+rack (default): --compose-cidr <CIDR> [--maas-url-ip <IP>] +# rack: --region-url <http://REGION-IP:5240/MAAS> --enroll-secret-file <path> # -# Installs a VR1 SITE HEADEND on the host it runs on: MAAS (region+rack) + LXD, with that -# LXD registered back into MAAS as an LXD VM host so MAAS can COMPOSE the site's non-stack -# service machines into it. This is D-114, and it is VR0's proven `lxd` + `tailscale` -# pattern applied per site. +# Installs a VR1 SITE HEADEND on the host it runs on. Two roles (D-123 rules ONE MAAS +# region on Office1 + a rack controller PER DC): # -# RUN IT ON THE SITE HOST (voffice1 today; vvr1-dc0/vvr1-dc1 later), as root: +# --role region+rack (DEFAULT -- unchanged legacy behavior): MAAS (region+rack) + LXD, with +# that LXD registered back into MAAS as an LXD VM host so MAAS can COMPOSE the site's +# non-stack service machines into it. This is D-114, VR0's proven `lxd` + `tailscale` +# pattern applied per site. This is the Office1 (voffice1) build. +# +# --role rack: a DC rack controller (vvr1-dc0) that ENROLLS to Office1's EXISTING region +# (D-123). A rack runs NO PostgreSQL, does its own `maas init rack`, and composes +# NOTHING -- the DC's OpenStack nodes are OpenTofu-created and DISCOVERED via the vcloud +# virsh vm-host registered to the REGION (not composed here). It therefore SKIPS all of +# the region DB / createadmin / LXD / vm-host / compose-DHCP work below. Enabling +# DHCP-on-metal-admin is a REGION-side op (Office1's MAAS, naming this rack as +# primary_rack) done as a RUNBOOK step -- NOT by this script (see rack note below). +# +# RUN IT ON THE SITE HOST, as root: +# region+rack (voffice1): # ssh <site-host> 'sudo bash -s' -- --compose-cidr 10.10.1.0/24 < scripts/site-headend-install.sh +# rack (vvr1-dc0): the region operator reads the enrollment secret from the REGION host's +# /var/snap/maas/common/maas/secret and drops it into a 0600 file on this host first: +# ssh <dc-rack> 'sudo bash -s' -- --role rack \ +# --region-url http://10.10.0.20:5240/MAAS --enroll-secret-file /root/region-enroll.secret \ +# < scripts/site-headend-install.sh # # --check report what is installed/configured; mutate nothing; exit 0 ok / 1 incomplete # --dry-run print what it would do; mutate nothing # (no flag) install + configure (idempotent; safe to re-run) # -# EXIT: 0 ok | 1 check-failed | 2 bad args | 3 unsupported OS | 4 install failed. ASCII + LF. +# EXIT: 0 ok | 1 check-failed | 2 bad args (incl. bad/missing --role args) | 3 unsupported OS +# | 4 install failed (incl. rack: enrollment-secret file absent/empty at mutate time). ASCII + LF. # # ===================================================================================== # THE FOUR TRAPS THIS SCRIPT EXISTS TO ENCODE (all hit for real on 2026-07-13; do not @@ -49,31 +69,60 @@ # servers on one L2 is an intermittent failure that is genuinely unpleasant to # diagnose. --compose-cidr is REQUIRED and has no default precisely so this can never # be picked by accident, and the script REFUSES to touch DHCP on any other subnet. +# +# TRAPS 1-3 (LXD/dnsmasq) DO NOT APPLY IN --role rack: a rack installs no LXD, creates no +# bridge, and runs no dnsmasq, so the :53/:67 conflict and the 5.21-track pin are moot. +# Trap 4's SPIRIT is kept absolutely: rack mode configures NO DHCP at all (metal-admin DHCP +# is a REGION-side op, not this script's), so it cannot fight the edge's Kea by construction. # ===================================================================================== set -uo pipefail CHECK=0; DRYRUN=0; COMPOSE_CIDR=""; MAAS_IP="" +ROLE="region+rack"; REGION_URL=""; ENROLL_SECRET_FILE="" while [ $# -gt 0 ]; do case "$1" in - --check) CHECK=1 ;; - --dry-run) DRYRUN=1 ;; - --compose-cidr) shift; COMPOSE_CIDR="${1:-}" ;; - --maas-url-ip) shift; MAAS_IP="${1:-}" ;; + --check) CHECK=1 ;; + --dry-run) DRYRUN=1 ;; + --role) shift; ROLE="${1:-}" ;; + --compose-cidr) shift; COMPOSE_CIDR="${1:-}" ;; + --maas-url-ip) shift; MAAS_IP="${1:-}" ;; + --region-url) shift; REGION_URL="${1:-}" ;; + --enroll-secret-file) shift; ENROLL_SECRET_FILE="${1:-}" ;; -h|--help) - echo "usage: site-headend-install.sh [--check|--dry-run] --compose-cidr <CIDR> [--maas-url-ip <IP>]" + echo "usage: site-headend-install.sh [--check|--dry-run] [--role <region+rack|rack>]" + echo " region+rack (default): --compose-cidr <CIDR> [--maas-url-ip <IP>]" + echo " rack: --region-url <http://REGION-IP:5240/MAAS> --enroll-secret-file <path>" exit 0 ;; *) echo "FAIL: unknown arg '$1'" >&2; exit 2 ;; esac shift done -[ -n "$COMPOSE_CIDR" ] || { echo "FAIL: --compose-cidr is REQUIRED (e.g. 10.10.1.0/24). It has no default on purpose: MAAS must serve DHCP on the compose network ONLY, never on the site LAN, which the edge's Kea already owns." >&2; exit 2; } -echo "$COMPOSE_CIDR" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.0/24$' || { echo "FAIL: --compose-cidr must be a /24 like 10.10.1.0/24 (got '$COMPOSE_CIDR')" >&2; exit 2; } +case "$ROLE" in + region+rack|rack) ;; + *) echo "FAIL: --role must be 'region+rack' (default) or 'rack' (got '$ROLE')" >&2; exit 2 ;; +esac -BASE="${COMPOSE_CIDR%.0/24}" # 10.10.1 -BRIDGE_IP="${BASE}.1" -RANGE_LO="${BASE}.100" -RANGE_HI="${BASE}.200" +# Per-role REQUIRED args are validated UNCONDITIONALLY here (mirrors the legacy --compose-cidr +# check, which already gated --check too), so a bad invocation is rejected before any mode runs. +if [ "$ROLE" = "rack" ]; then + # rack enrolls to Office1's EXISTING region -- no compose network (LXD-compose is a region + # concern); it needs the region URL + the region's enrollment secret (read from a file, never + # echoed -- same discipline as $SECRETS below; the region operator obtains it from the region + # host's /var/snap/maas/common/maas/secret). + [ -n "$REGION_URL" ] || { echo "FAIL: --role rack REQUIRES --region-url <http://REGION-IP:5240/MAAS>" >&2; exit 2; } + echo "$REGION_URL" | grep -qiE '^https?://.+/MAAS/?$' || { echo "FAIL: --region-url must look like http://REGION-IP:5240/MAAS (got '$REGION_URL')" >&2; exit 2; } + [ -n "$ENROLL_SECRET_FILE" ] || { echo "FAIL: --role rack REQUIRES --enroll-secret-file <path> (the region's enrollment secret; NEVER passed on the command line)" >&2; exit 2; } + # --compose-cidr is NOT used in rack mode; ignore it if present. +else + [ -n "$COMPOSE_CIDR" ] || { echo "FAIL: --compose-cidr is REQUIRED (e.g. 10.10.1.0/24). It has no default on purpose: MAAS must serve DHCP on the compose network ONLY, never on the site LAN, which the edge's Kea already owns." >&2; exit 2; } + echo "$COMPOSE_CIDR" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.0/24$' || { echo "FAIL: --compose-cidr must be a /24 like 10.10.1.0/24 (got '$COMPOSE_CIDR')" >&2; exit 2; } + + BASE="${COMPOSE_CIDR%.0/24}" # 10.10.1 + BRIDGE_IP="${BASE}.1" + RANGE_LO="${BASE}.100" + RANGE_HI="${BASE}.200" +fi SECRETS=/root/maas-secrets MAAS_CHANNEL="3.7/stable" PG_CHANNEL="16/stable" @@ -113,14 +162,81 @@ return "$bad" } +# ---------------- check mode, RACK role (read-only) ---------------- +# A rack runs NO PostgreSQL and NO LXD, so we assert only: the maas snap is present and +# `maas status` shows `rackd` running. If the rack has been through `maas init rack`, the +# region URL it enrolled to is recorded (offline-readable) in the rackd config -- report it +# as the connected-to-region signal, best-effort (never fatal; the file is root-only). +report_rack() { + local bad=0 + snap list maas >/dev/null 2>&1 && echo "OK: maas snap $(snap list maas 2>/dev/null | awk 'NR>1{print $2" ("$4")"}')" || { echo "ABSENT: maas snap"; bad=1; } + if have maas && maas status 2>/dev/null | grep -q rackd; then echo "OK: MAAS rackd running"; else echo "ABSENT: MAAS rackd not running (not enrolled to a region)"; bad=1; fi + local ru; ru="$(grep -sh -m1 -E '^maas_url:' /var/snap/maas/current/rackd.conf 2>/dev/null | awk '{print $2}')" + [ -n "$ru" ] && echo "OK: enrolled to region $ru" + return "$bad" +} + +# Dispatch check/precheck to the role-appropriate reporter. +do_report() { if [ "$ROLE" = "rack" ]; then report_rack; else report; fi; } + report_st=0 -if [ "$CHECK" = "1" ]; then report; exit $?; fi -report >/dev/null 2>&1; report_st=$? +if [ "$CHECK" = "1" ]; then do_report; exit $?; fi +do_report >/dev/null 2>&1; report_st=$? [ "$report_st" = "0" ] && [ "$DRYRUN" = "0" ] && { echo "-- site headend already installed and configured --"; exit 0; } have apt-get || { echo "FAIL: Debian/Ubuntu (apt) only." >&2; exit 3; } # root is required to MUTATE, not to plan: --dry-run stays runnable by anyone (and by the harness). [ "$DRYRUN" = "1" ] || [ "$(id -u)" = "0" ] || { echo "FAIL: must run as root (use: ssh <host> 'sudo bash -s' -- ... < $0)" >&2; exit 4; } + +# ===================================================================================== +# RACK ROLE (D-123): enroll a DC rack controller to Office1's EXISTING region. ONLY these +# steps run -- and NONE of the region+rack work below (no PostgreSQL, no `maas init +# region+rack`, no createadmin/apikey/login, no LXD, no LXD vm-host registration, no +# compose-network DHCP). Rationale: a rack runs no DB, composes nothing (the DC's OpenStack +# nodes are OpenTofu-created + discovered via the vcloud virsh vm-host registered to the +# REGION, not composed here), and DHCP-on-metal-admin is a REGION-side op (enabled against +# Office1's MAAS naming this rack as primary_rack -- a runbook step, NOT this script). So +# traps 1-3 (LXD/dnsmasq) do not apply, and trap 4's spirit holds: rack mode touches NO DHCP. +# ===================================================================================== +if [ "$ROLE" = "rack" ]; then + echo "== 1. time: MAAS manages time via chrony; systemd-timesyncd conflicts ==" + run "disable systemd-timesyncd" -- systemctl disable --now systemd-timesyncd >/dev/null 2>&1 + + echo "== 2. MAAS $MAAS_CHANNEL (rack: NO PostgreSQL -- the rack runs no DB) ==" + snap list maas >/dev/null 2>&1 || run "install maas" -- snap install maas --channel="$MAAS_CHANNEL" || exit 4 + + echo "== 3. maas init rack -- enroll to the region at $REGION_URL ==" + # `maas init rack --maas-url <URL> --secret <SECRET>` is the MAAS snap rack-registration + # invocation -- CONFIRMED, not invented: this form is documented across the MAAS snap docs and + # discourse and is stable across 3.x (so it holds for our 3.7/stable pin), and the region's + # shared secret lives at /var/snap/maas/common/maas/secret on the region host (matches the + # runbook). The secret is READ from the file at + # execution time and NEVER echoed -- so this step is hand-rolled rather than routed through + # run() (which would print the expanded command, leaking the secret in --dry-run). + if [ "$DRYRUN" = "0" ]; then + [ -s "$ENROLL_SECRET_FILE" ] || { echo "FAIL: --enroll-secret-file '$ENROLL_SECRET_FILE' is absent or empty (need the region's enrollment secret)" >&2; exit 4; } + if maas status 2>/dev/null | grep -q rackd; then + echo " -> already enrolled (rackd running)" + else + maas init rack --maas-url "$REGION_URL" --secret "$(cat "$ENROLL_SECRET_FILE")" >/dev/null 2>&1 \ + || { echo "FAIL: maas init rack" >&2; exit 4; } + echo " -> rack registered to region $REGION_URL" + fi + else + echo " [dry-run] maas init rack --maas-url $REGION_URL --secret <read from $ENROLL_SECRET_FILE at run time; never printed>" + fi + + # SKIPPED IN RACK MODE (region concerns): PostgreSQL, maas init region+rack, + # createadmin/apikey/login, LXD (5.21 pin), LXD vm-host registration, compose-network DHCP. + # metal-admin DHCP (this rack as primary_rack) is enabled REGION-side as a runbook step. + if [ "$DRYRUN" = "1" ]; then echo " (dry-run: no changes made)"; exit 0; fi + echo + echo "RACK ENROLLED to $REGION_URL. Next (REGION-side runbook, NOT this script): enable" + echo "DHCP on metal-admin against Office1's MAAS, naming this host as primary_rack." + exit 0 +fi + +# ---- region+rack role (default; unchanged legacy behavior) ---- [ -n "$MAAS_IP" ] || MAAS_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')" [ -n "$MAAS_IP" ] || { echo "FAIL: could not measure this host's outbound IP; pass --maas-url-ip" >&2; exit 4; } diff --git a/tests/dc-edge-wan-import/fake_netbox.py b/tests/dc-edge-wan-import/fake_netbox.py new file mode 100644 index 0000000..0dc65fb --- /dev/null +++ b/tests/dc-edge-wan-import/fake_netbox.py @@ -0,0 +1,43 @@ +""" +In-memory stand-in for the NB client in netbox/dc-edge-wan-import.py. + +No real NetBox / network access exists this session -- this fake implements just +the two methods the target script calls on its client (.one() and .create()) so the +REAL main()/preflight can be driven end-to-end without a live server. It is injected +at the get_nb() seam, exactly as tests/dc-dc-prefixes-import/ injects a fake pynetbox. + +FIDELITY NOTE. The target's real .one() issues GET ...?<filter>&limit=1 and takes +results[0] -- so it is single-match by construction and CANNOT raise on a duplicate +prefix (unlike pynetbox.get(), which the sibling fake models raising on multi-match). +This fake matches THAT contract: .one() returns the first record whose fields equal +the filter, or None. For an idempotency SKIP that is the safe direction -- a +pre-existing prefix reads as "present" and is not duplicated. .create() records every +write so a test can assert a rejected/dry run wrote NOTHING. +""" +from __future__ import annotations + + +class FakeNB: + def __init__(self, roles=(), prefixes=(), sites=()): + # store: endpoint path -> list of record dicts (as the API would return them) + self.store = { + "ipam/roles": [dict(r) for r in roles], + "ipam/prefixes": [dict(p) for p in prefixes], + "dcim/sites": [dict(s) for s in sites], + } + self.creates = [] # (path, payload) for every .create() -- the write ledger + self._next_id = 1000 + + def one(self, path, **flt): + for rec in self.store.get(path, []): + if all(str(rec.get(k)) == str(v) for k, v in flt.items()): + return rec + return None + + def create(self, path, payload): + self.creates.append((path, dict(payload))) + rec = dict(payload) + rec["id"] = self._next_id + self._next_id += 1 + self.store.setdefault(path, []).append(rec) + return rec diff --git a/tests/dc-edge-wan-import/run-tests.sh b/tests/dc-edge-wan-import/run-tests.sh new file mode 100644 index 0000000..a5aa1e1 --- /dev/null +++ b/tests/dc-edge-wan-import/run-tests.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# tests/dc-edge-wan-import/run-tests.sh +# +# Harness for netbox/dc-edge-wan-import.py. OFFLINE -- touches no NetBox. +# +# Two layers, both required (mirrors d120-compose-bands + dc-dc-prefixes-import): +# 1. STATIC greps that PIN the D-115 edge values + the guards every sandbox-loop +# writer must have (dry-by-default, upstream-write gated IN CODE, whole-plan +# preflight). A changed constant that a behavioral test would ALSO change is +# caught here. +# 2. test_logic.py drives the REAL main()/preflight against an in-memory fake NB +# (injected at the get_nb() seam) -- half-write guard, scope-not-swapped, +# idempotent-skip, dry-run-writes-nothing. In-process on purpose: no port / +# readiness / cleanup flakiness to redden the shared gauntlet. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../netbox" && pwd)/dc-edge-wan-import.py" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required"; exit 1; } + +python3 -c "import ast;ast.parse(open('$S').read())" 2>/dev/null && ok || bad "does not parse" +python3 "$S" --help >/dev/null 2>&1 && ok || bad "--help -> 0" + +# no env -> must fail loud, not guess a target +NETBOX_URL= NETBOX_TOKEN= python3 "$S" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing NETBOX_URL/TOKEN must fail" + +# DRY BY DEFAULT +grep -q '"--commit", action="store_true"' "$S" && ok || bad "no --commit flag -- must be dry by default" +grep -q 'DRY RUN -- nothing will be written' "$S" && ok || bad "does not announce its dry run" + +# UPSTREAM WRITE GATED IN CODE (not by discipline) +grep -q 'yes-write-upstream' "$S" && ok || bad "lost the --yes-write-upstream gate" +grep -q 'SANDBOX_HOSTS' "$S" && ok || bad "lost the SANDBOX_HOSTS gate" +out="$(NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=x python3 "$S" --commit 2>&1)" +printf '%s' "$out" | grep -q "REFUSING to --commit" && ok \ + || bad "did NOT refuse a --commit to a non-sandbox host (it would have written to production)" + +# WAF: UA-aware or every upstream call 403s (looks like auth failure) +grep -q 'User-Agent' "$S" && grep -q 'curl/8.5.0' "$S" && ok || bad "lost the WAF-safe User-Agent" + +# WHOLE-PLAN PREFLIGHT -- validate role/container/placement/site BEFORE any create +grep -q "outside the Edge container" "$S" && ok || bad "lost the in-container preflight" +grep -q "container .* absent" "$S" && ok || bad "lost the container-exists preflight" +grep -q "role '.*' absent" "$S" && ok || bad "lost the role-exists preflight" +grep -q "site '.*' absent" "$S" && ok || bad "lost the site-resolves preflight" + +# THE D-115 EDGE VALUES -- the two DC /24s, their role, and their SITE scope. +grep -qF '("172.30.2.0/24", "vr1-dc0"' "$S" && ok || bad "172.30.2.0/24 -> vr1-dc0 binding is GONE / changed" +grep -qF '("172.30.3.0/24", "vr1-dc1"' "$S" && ok || bad "172.30.3.0/24 -> vr1-dc1 binding is GONE / changed" +grep -qF 'ROLE_SLUG = "edge"' "$S" && ok || bad "edge role slug changed" +grep -qF 'CONTAINER = "172.30.0.0/16"' "$S" && ok || bad "Edge container is not 172.30.0.0/16" +grep -q '"scope_type": "dcim.site"' "$S" && ok || bad "the /24s are not dcim.site-scoped (must match office1-wan)" + +# This tool must NOT create the role/container/sites -- they are preconditions. +grep -q 'ipam/roles".*payload\|create("ipam/roles"\|create("dcim/sites"' "$S" \ + && bad "this tool CREATES a role/site -- those are preconditions, it must only create the /24s" || ok + +# ---- behavioral layer: drive the real main() against the injected fake NB ---- +echo " -- behavioral (test_logic.py) --" +tl_out="$(python3 "$HERE/test_logic.py" 2>&1)"; tl_rc=$? +printf '%s\n' "$tl_out" | grep -E '^(FAIL|PASS: |test_logic:)' | sed 's/^/ /' +tl_n="$(printf '%s\n' "$tl_out" | sed -n 's/^test_logic: ALL PASS (\([0-9]*\) checks)$/\1/p')" +if [ "$tl_rc" -eq 0 ] && [ -n "$tl_n" ]; then + pass=$((pass + tl_n)) +else + bad "test_logic.py behavioral suite FAILED (rc=$tl_rc)" +fi + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "dc-edge-wan-import: $pass/$total PASS"; exit 0; fi +echo "dc-edge-wan-import: $fail/$total FAIL"; exit 1 diff --git a/tests/dc-edge-wan-import/test_logic.py b/tests/dc-edge-wan-import/test_logic.py new file mode 100644 index 0000000..01ac01a --- /dev/null +++ b/tests/dc-edge-wan-import/test_logic.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Behavioral tests for netbox/dc-edge-wan-import.py -- drives the REAL main()/preflight +against an in-memory fake NB (tests/dc-edge-wan-import/fake_netbox.py) injected at the +get_nb() seam. No live NetBox. Invoked by run-tests.sh alongside the static greps. + +The properties pinned here map to this repo's documented IPAM bug classes: + * HALF-WRITE (roles-aggregates-import.py): a later target's missing precondition + must abort with ZERO creates, never "wrote dc0 then died". + * SWAPPED SCOPE (the D-117 wrong-target near-miss): the vr1-dc0 /24 must bind + vr1-dc0's id and the vr1-dc1 /24 must bind vr1-dc1's id -- not shared or swapped. + * ALREADY-PRESENT = SKIP (idempotent), distinct from missing-infra = die. + * DRY BY DEFAULT: no --commit writes NOTHING. + * UPSTREAM-WRITE guard: --commit to a non-sandbox host refuses without the flag. +""" +from __future__ import annotations + +import contextlib +import faulthandler +import importlib.util +import inspect +import io +import os +import sys + +faulthandler.dump_traceback_later(30, exit=True) + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +TARGET_PATH = os.path.join(REPO_ROOT, "netbox", "dc-edge-wan-import.py") +sys.path.insert(0, HERE) + +import fake_netbox # noqa: E402 + +P = 0 +F = 0 + + +def ok(label): + global P + P += 1 + print(f"PASS: {label}") + + +def no(label, detail=""): + global F + F += 1 + print(f"FAIL: {label}" + (f" ({detail})" if detail else "")) + + +def check(cond, label, detail=""): + ok(label) if cond else no(label, detail) + + +def load_target(): + spec = importlib.util.spec_from_file_location("dc_edge_wan_import", TARGET_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +T = load_target() + + +@contextlib.contextmanager +def captured_stdout(): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + yield buf + + +def run_main(argv, fake): + T.get_nb = lambda base=None, token=None: fake + old = sys.argv + sys.argv = ["dc-edge-wan-import.py"] + argv + try: + return T.main() + finally: + sys.argv = old + + +def run_dies(label, argv, fake): + # swallow stdout AND stderr -- die() prints "FAIL: ..." to stderr by design, and + # for an EXPECTED death that is not a test failure, just noise on the gauntlet. + try: + with contextlib.redirect_stderr(io.StringIO()), captured_stdout(): + run_main(argv, fake) + no(label, "did not raise SystemExit") + except SystemExit: + ok(label) + + +# Full-precondition fixture builders (role + container + both DC sites present). +ROLE = {"slug": "edge", "name": "Edge", "id": 1} +CONTAINER = {"prefix": "172.30.0.0/16", "id": 10} +SITE_DC0 = {"slug": "vr1-dc0", "name": "VR1 DC0", "id": 20} +SITE_DC1 = {"slug": "vr1-dc1", "name": "VR1 DC1", "id": 21} + + +def full_fake(prefixes_extra=()): + return fake_netbox.FakeNB( + roles=[ROLE], + prefixes=[CONTAINER, *prefixes_extra], + sites=[SITE_DC0, SITE_DC1], + ) + + +os.environ["NETBOX_URL"] = "http://10.10.1.10:8000" # a known sandbox (guard passes) +os.environ["NETBOX_TOKEN"] = "fake-token" + +# ----------------------------------------------------------------------------- +# 1. DRY RUN is the default and writes NOTHING. +# ----------------------------------------------------------------------------- +fk = full_fake() +with captured_stdout() as out: + rc = run_main([], fk) +check(rc == 0, "dry run rc==0", str(rc)) +check(out.getvalue().count("would CREATE") == 2, "dry run PLANS both /24s", + str(out.getvalue().count("would CREATE"))) +check(out.getvalue().count("CREATED ") == 0, "dry run CREATES nothing (no CREATED line)") +check("DRY RUN" in out.getvalue(), "dry run says so, loudly") +check(len(fk.creates) == 0, "dry run left the NetBox untouched (0 writes)", str(len(fk.creates))) + +# ----------------------------------------------------------------------------- +# 2. --commit writes exactly the two /24s, correctly scoped (NO swap). +# ----------------------------------------------------------------------------- +fk = full_fake() +with captured_stdout() as out: + rc = run_main(["--commit"], fk) +check(rc == 0, "commit rc==0", str(rc)) +check(len(fk.creates) == 2, "commit created exactly 2 prefixes", str(len(fk.creates))) +check(all(path == "ipam/prefixes" for path, _ in fk.creates), + "commit wrote only ipam/prefixes (no stray sites/roles/container)") +by_prefix = {pl["prefix"]: pl for _, pl in fk.creates} +check(by_prefix.get("172.30.2.0/24", {}).get("scope_id") == 20, + "SCOPE: 172.30.2.0/24 binds vr1-dc0's id (20)", str(by_prefix.get("172.30.2.0/24"))) +check(by_prefix.get("172.30.3.0/24", {}).get("scope_id") == 21, + "SCOPE: 172.30.3.0/24 binds vr1-dc1's id (21) -- not swapped", + str(by_prefix.get("172.30.3.0/24"))) +check(all(pl.get("scope_type") == "dcim.site" for pl in by_prefix.values()), + "SCOPE: both /24s are dcim.site-scoped (matches office1-wan)") +check(all(pl.get("role") == 1 for pl in by_prefix.values()), "both /24s carry the edge role id") +check(all(pl.get("status") == "active" for pl in by_prefix.values()), "both /24s are status active") + +# ----------------------------------------------------------------------------- +# 3. Already-present = idempotent SKIP (both present -> 0 creates, exit clean). +# ----------------------------------------------------------------------------- +fk = full_fake(prefixes_extra=[{"prefix": "172.30.2.0/24", "id": 30}, + {"prefix": "172.30.3.0/24", "id": 31}]) +with captured_stdout() as out: + rc = run_main(["--commit"], fk) +check(rc == 0, "both-present idempotent run rc==0") +check(out.getvalue().count("EXISTS") == 2, "both-present run reports both as EXISTS") +check(len(fk.creates) == 0, "both-present run creates nothing new") + +# ----------------------------------------------------------------------------- +# 4. Partial-present: dc0 already there, dc1 absent -> creates ONLY dc1. +# ----------------------------------------------------------------------------- +fk = full_fake(prefixes_extra=[{"prefix": "172.30.2.0/24", "id": 30}]) +with captured_stdout(): + rc = run_main(["--commit"], fk) +check(rc == 0, "partial-present run rc==0") +check(len(fk.creates) == 1 and fk.creates[0][1]["prefix"] == "172.30.3.0/24", + "partial-present run creates ONLY the missing 172.30.3.0/24", + str([pl["prefix"] for _, pl in fk.creates])) + +# ----------------------------------------------------------------------------- +# 5. HALF-WRITE GUARD -- a LATER target's missing site aborts with ZERO creates. +# role + container + vr1-dc0 present, but vr1-dc1 SITE absent. +# ----------------------------------------------------------------------------- +fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[CONTAINER], sites=[SITE_DC0]) +run_dies("half-write: missing vr1-dc1 site is REJECTED before any write", ["--commit"], fk) +check(len(fk.creates) == 0, "half-write: the rejected run wrote NOTHING (vr1-dc0 not created)") + +# ----------------------------------------------------------------------------- +# 6-8. Missing infra = die (each precondition), each writing nothing. +# ----------------------------------------------------------------------------- +fk = fake_netbox.FakeNB(roles=[], prefixes=[CONTAINER], sites=[SITE_DC0, SITE_DC1]) +run_dies("missing edge role is REJECTED", ["--commit"], fk) +check(len(fk.creates) == 0, "missing-role run wrote nothing") + +fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[], sites=[SITE_DC0, SITE_DC1]) +run_dies("missing 172.30.0.0/16 container is REJECTED", ["--commit"], fk) +check(len(fk.creates) == 0, "missing-container run wrote nothing") + +fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[CONTAINER], sites=[SITE_DC1]) +run_dies("missing vr1-dc0 site is REJECTED", ["--commit"], fk) +check(len(fk.creates) == 0, "missing-dc0-site run wrote nothing") + +# ----------------------------------------------------------------------------- +# 9. /24 OUTSIDE the container is rejected by the local subnet preflight. +# ----------------------------------------------------------------------------- +_saved = T.TARGETS +T.TARGETS = [("10.0.0.0/24", "vr1-dc0", "bogus -- not inside 172.30.0.0/16")] +try: + run_dies("a /24 outside the Edge container is REJECTED", ["--commit"], full_fake()) +finally: + T.TARGETS = _saved + +# ----------------------------------------------------------------------------- +# 10. Missing NETBOX_URL/TOKEN fails loud (does not guess a target). +# ----------------------------------------------------------------------------- +_u = os.environ.pop("NETBOX_URL", None) +run_dies("missing NETBOX_URL fails loud", [], full_fake()) +os.environ["NETBOX_URL"] = _u + +# ----------------------------------------------------------------------------- +# 11. UPSTREAM-WRITE guard: --commit to a non-sandbox host refuses without the flag, +# proceeds with it. Sandbox needs no flag (covered above). +# ----------------------------------------------------------------------------- +os.environ["NETBOX_URL"] = "https://netbox.baldurkeep.com" # the production apex +fk = full_fake() +run_dies("upstream --commit WITHOUT --yes-write-upstream is REFUSED", ["--commit"], fk) +check(len(fk.creates) == 0, "the refused upstream --commit wrote NOTHING") + +fk = full_fake() +with captured_stdout(): + rc = run_main(["--commit", "--yes-write-upstream"], fk) +check(rc == 0 and len(fk.creates) == 2, + "upstream --commit --yes-write-upstream IS allowed and writes both /24s", + str((rc, len(fk.creates)))) +os.environ["NETBOX_URL"] = "http://10.10.1.10:8000" + +# ----------------------------------------------------------------------------- +# 12. Structural pins (a changed constant a behavioral test alone would miss). +# ----------------------------------------------------------------------------- +check(T.ROLE_SLUG == "edge", "ROLE_SLUG is 'edge'") +check(T.CONTAINER == "172.30.0.0/16", "CONTAINER is 172.30.0.0/16") +check(T.STATUS == "active", "STATUS is active (like office1-wan .1/24)") +check([c for c, _, _ in T.TARGETS] == ["172.30.2.0/24", "172.30.3.0/24"], + "TARGETS pins exactly the two DC /24s .2 and .3") +check([s for _, s, _ in T.TARGETS] == ["vr1-dc0", "vr1-dc1"], + "TARGETS scopes .2->vr1-dc0 and .3->vr1-dc1 (no swap in the source)") +check(T.SANDBOX_HOSTS == {"localhost", "127.0.0.1", "10.10.1.10"}, + "SANDBOX_HOSTS matches the sibling importers") +check("get_nb" in dir(T) and callable(T.get_nb), "get_nb() injection seam exists") +_src = inspect.getsource(T) +check('User-Agent"' in _src and "curl/8.5.0" in _src, "UA-aware (WAF-safe User-Agent set)") + +print() +if F == 0: + print(f"test_logic: ALL PASS ({P} checks)") + sys.exit(0) +print(f"test_logic: {F} FAIL of {P + F}") +sys.exit(1) diff --git a/tests/dc-rack-mgmt-import/fake_netbox.py b/tests/dc-rack-mgmt-import/fake_netbox.py new file mode 100644 index 0000000..09199e8 --- /dev/null +++ b/tests/dc-rack-mgmt-import/fake_netbox.py @@ -0,0 +1,45 @@ +""" +In-memory stand-in for the NB client in netbox/dc-rack-mgmt-import.py. + +No real NetBox / network access exists this session -- this fake implements just +the two methods the target script calls on its client (.one() and .create()) so the +REAL main()/preflight can be driven end-to-end without a live server. It is injected +at the get_nb() seam, exactly as tests/dc-edge-wan-import/ injects its fake. + +FIDELITY NOTE. The target's real .one() issues GET ...?<filter>&limit=1 and takes +results[0] -- so it is single-match by construction and CANNOT raise on a duplicate +(unlike pynetbox.get(), which the older sibling fake models raising on multi-match). +This fake matches THAT contract: .one() returns the first record whose fields equal +the filter, or None. For an idempotency SKIP that is the safe direction -- a +pre-existing object reads as "present" and is not duplicated. .create() records every +write so a test can assert a rejected/dry run wrote NOTHING. The store is keyed by +path, so ipam/prefixes, ipam/roles, dcim/sites AND ipam/ip-addresses all work. +""" +from __future__ import annotations + + +class FakeNB: + def __init__(self, roles=(), prefixes=(), sites=(), ip_addresses=()): + # store: endpoint path -> list of record dicts (as the API would return them) + self.store = { + "ipam/roles": [dict(r) for r in roles], + "ipam/prefixes": [dict(p) for p in prefixes], + "dcim/sites": [dict(s) for s in sites], + "ipam/ip-addresses": [dict(a) for a in ip_addresses], + } + self.creates = [] # (path, payload) for every .create() -- the write ledger + self._next_id = 1000 + + def one(self, path, **flt): + for rec in self.store.get(path, []): + if all(str(rec.get(k)) == str(v) for k, v in flt.items()): + return rec + return None + + def create(self, path, payload): + self.creates.append((path, dict(payload))) + rec = dict(payload) + rec["id"] = self._next_id + self._next_id += 1 + self.store.setdefault(path, []).append(rec) + return rec diff --git a/tests/dc-rack-mgmt-import/run-tests.sh b/tests/dc-rack-mgmt-import/run-tests.sh new file mode 100644 index 0000000..d8ca624 --- /dev/null +++ b/tests/dc-rack-mgmt-import/run-tests.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# tests/dc-rack-mgmt-import/run-tests.sh +# +# Harness for netbox/dc-rack-mgmt-import.py. OFFLINE -- touches no NetBox. +# +# Two layers, both required (mirrors dc-edge-wan-import + d120-compose-bands): +# 1. STATIC greps that PIN the D-124 scheme values + the guards every sandbox-loop +# writer must have (dry-by-default, upstream-write gated IN CODE, whole-plan +# preflight, NO invented literal). A changed constant a behavioral test would ALSO +# change is caught here. +# 2. test_logic.py drives the REAL main()/preflight against an in-memory fake NB +# (injected at the get_nb() seam) -- half-write guard, role/scope binding, band +# arithmetic, idempotent-skip, dry-run-writes-nothing. In-process on purpose: no +# port / readiness / cleanup flakiness to redden the shared gauntlet. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../netbox" && pwd)/dc-rack-mgmt-import.py" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required"; exit 1; } + +python3 -c "import ast;ast.parse(open('$S').read())" 2>/dev/null && ok || bad "does not parse" +python3 "$S" --help >/dev/null 2>&1 && ok || bad "--help -> 0" + +# no env -> must fail loud, not guess a target +NETBOX_URL= NETBOX_TOKEN= python3 "$S" --transit-cidr 10.12.60.0/30 --rack-ip 10.12.8.5 \ + >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing NETBOX_URL/TOKEN must fail" + +# NO INVENTED LITERAL -- both inputs are REQUIRED (no default CIDR/IP baked in) +NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --rack-ip 10.12.8.5 \ + >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing --transit-cidr must fail (no invented CIDR)" +NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --transit-cidr 10.12.60.0/30 \ + >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing --rack-ip must fail (no invented IP)" +grep -q 'default=os.environ.get("TRANSIT_CIDR")' "$S" && ok || bad "lost the TRANSIT_CIDR env fallback" +grep -q 'default=os.environ.get("RACK_IP")' "$S" && ok || bad "lost the RACK_IP env fallback" +# guard against a baked-in transit /30 or /31 literal masquerading as a default +grep -qE '=\s*"10\.[0-9]+\.[0-9]+\.[0-9]+/3[01]"' "$S" && bad "a /30 or /31 literal is baked in -- must be an INPUT" || ok + +# DRY BY DEFAULT +grep -q '"--commit", action="store_true"' "$S" && ok || bad "no --commit flag -- must be dry by default" +grep -q 'DRY RUN -- nothing will be written' "$S" && ok || bad "does not announce its dry run" + +# UPSTREAM WRITE GATED IN CODE (not by discipline) +grep -q 'yes-write-upstream' "$S" && ok || bad "lost the --yes-write-upstream gate" +grep -q 'SANDBOX_HOSTS' "$S" && ok || bad "lost the SANDBOX_HOSTS gate" +out="$(NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=x python3 "$S" \ + --transit-cidr 10.12.60.0/30 --rack-ip 10.12.8.5 --commit 2>&1)" +printf '%s' "$out" | grep -q "REFUSING to --commit" && ok \ + || bad "did NOT refuse a --commit to a non-sandbox host (it would have written to production)" + +# WAF: UA-aware or every upstream call 403s (looks like auth failure) +grep -q 'User-Agent' "$S" && grep -q 'curl/8.5.0' "$S" && ok || bad "lost the WAF-safe User-Agent" + +# WHOLE-PLAN PREFLIGHT -- validate role/container/transit/band/site BEFORE any create +grep -q "outside the container" "$S" && ok || bad "lost the transit-in-container preflight" +grep -q "container .* absent" "$S" && ok || bad "lost the container-exists preflight" +grep -q "role '.*' absent" "$S" && ok || bad "lost the role-exists preflight" +grep -q "site '.*' absent" "$S" && ok || bad "lost the site-resolves preflight" +grep -q "GATEWAY" "$S" && ok || bad "lost the .1-gateway rejection" +grep -q "static band" "$S" && ok || bad "lost the D-120 static-band check" +grep -q "outside metal-admin" "$S" && ok || bad "lost the metal-admin containment check" +grep -q "must be a /30 or /31" "$S" && ok || bad "lost the /30-or-/31 shape check" + +# THE D-124 SCHEME VALUES -- role, container, site scope, metal-admin band, rack dns. +grep -qF 'ROLE_SLUG = "transit"' "$S" && ok || bad "transit role slug changed" +grep -qF 'CONTAINER = "10.12.0.0/16"' "$S" && ok || bad "container is not 10.12.0.0/16 (Cloud)" +grep -qF 'SITE_SLUG = "vr1-dc0"' "$S" && ok || bad "transit site scope is not vr1-dc0" +grep -qF 'METAL_ADMIN = "10.12.8.0/22"' "$S" && ok || bad "metal-admin is not 10.12.8.0/22" +grep -qF 'RACK_DNS = "vvr1-dc0"' "$S" && ok || bad "rack dns name is not vvr1-dc0" +grep -q '"scope_type": "dcim.site"' "$S" && ok || bad "the transit prefix is not dcim.site-scoped" + +# This tool must NOT create the role/container/site -- they are preconditions. +grep -q 'create("ipam/roles"\|create("dcim/sites"' "$S" \ + && bad "this tool CREATES a role/site -- those are preconditions" || ok + +# ---- behavioral layer: drive the real main() against the injected fake NB ---- +echo " -- behavioral (test_logic.py) --" +tl_out="$(python3 "$HERE/test_logic.py" 2>&1)"; tl_rc=$? +printf '%s\n' "$tl_out" | grep -E '^(FAIL|PASS: |test_logic:)' | sed 's/^/ /' +tl_n="$(printf '%s\n' "$tl_out" | sed -n 's/^test_logic: ALL PASS (\([0-9]*\) checks)$/\1/p')" +if [ "$tl_rc" -eq 0 ] && [ -n "$tl_n" ]; then + pass=$((pass + tl_n)) +else + bad "test_logic.py behavioral suite FAILED (rc=$tl_rc)" +fi + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "dc-rack-mgmt-import: $pass/$total PASS"; exit 0; fi +echo "dc-rack-mgmt-import: $fail/$total FAIL"; exit 1 diff --git a/tests/dc-rack-mgmt-import/test_logic.py b/tests/dc-rack-mgmt-import/test_logic.py new file mode 100644 index 0000000..8ec679e --- /dev/null +++ b/tests/dc-rack-mgmt-import/test_logic.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +Behavioral tests for netbox/dc-rack-mgmt-import.py -- drives the REAL main()/preflight +against an in-memory fake NB (tests/dc-rack-mgmt-import/fake_netbox.py) injected at the +get_nb() seam. No live NetBox. Invoked by run-tests.sh alongside the static greps. + +The properties pinned here map to this repo's documented IPAM bug classes + the D-124 +scheme (Scheme A -- transit-numbered mesh): + * HALF-WRITE (roles-aggregates-import.py): a bad rack IP (the .1 gateway) must abort + with ZERO creates, never "wrote the transit prefix then died on the rack IP". + * ROLE/SCOPE binding: the transit prefix carries the `transit` role id and is + dcim.site-scoped to vr1-dc0; the rack IP carries NO ipam role (NetBox ip-address + `role` is a choice field) -- identity is in dns_name/description. + * BAND arithmetic: rack IP within metal-admin 10.12.8.0/22, D-120 static band .2-.49, + NOT the .1 gateway, NOT .0. + * TRANSIT shape: /30 or /31, host-bits-clear, subnet_of the Cloud container. + * ALREADY-PRESENT = SKIP (idempotent), distinct from missing-infra = die. + * DRY BY DEFAULT: no --commit writes NOTHING. + * UPSTREAM-WRITE guard: --commit to a non-sandbox host refuses without the flag. + * NO INVENTED LITERAL: missing --transit-cidr / --rack-ip fails loud. +""" +from __future__ import annotations + +import contextlib +import faulthandler +import importlib.util +import inspect +import io +import os +import sys + +faulthandler.dump_traceback_later(30, exit=True) + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +TARGET_PATH = os.path.join(REPO_ROOT, "netbox", "dc-rack-mgmt-import.py") +sys.path.insert(0, HERE) + +import fake_netbox # noqa: E402 + +P = 0 +F = 0 + + +def ok(label): + global P + P += 1 + print(f"PASS: {label}") + + +def no(label, detail=""): + global F + F += 1 + print(f"FAIL: {label}" + (f" ({detail})" if detail else "")) + + +def check(cond, label, detail=""): + ok(label) if cond else no(label, detail) + + +def load_target(): + spec = importlib.util.spec_from_file_location("dc_rack_mgmt_import", TARGET_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +T = load_target() + + +@contextlib.contextmanager +def captured_stdout(): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + yield buf + + +# The operator-supplied, NetBox-assigned example values under test. Chosen INSIDE the +# scheme: transit /30 subnet_of Cloud 10.12.0.0/16; rack IP in the .2-.49 static band. +TRANSIT = "10.12.60.0/30" +TRANSIT31 = "10.12.60.0/31" +RACK = "10.12.8.5" +RACK_ADDR = "10.12.8.5/22" # how the tool stores it (host + metal-admin /22 mask) + + +def run_main(argv, fake): + T.get_nb = lambda base=None, token=None: fake + old = sys.argv + sys.argv = ["dc-rack-mgmt-import.py"] + argv + try: + return T.main() + finally: + sys.argv = old + + +def run_dies(label, argv, fake): + # swallow stdout AND stderr -- die() prints "FAIL: ..." to stderr by design, and + # for an EXPECTED death that is not a test failure, just noise on the gauntlet. + try: + with contextlib.redirect_stderr(io.StringIO()), captured_stdout(): + run_main(argv, fake) + no(label, "did not raise SystemExit") + except SystemExit: + ok(label) + + +# Full-precondition fixture builders (transit role + Cloud container + vr1-dc0 site). +ROLE = {"slug": "transit", "name": "Transit", "id": 7} +CONTAINER = {"prefix": "10.12.0.0/16", "id": 10} +SITE_DC0 = {"slug": "vr1-dc0", "name": "VR1 DC0", "id": 20} + + +def full_fake(prefixes_extra=(), ip_extra=()): + return fake_netbox.FakeNB( + roles=[ROLE], + prefixes=[CONTAINER, *prefixes_extra], + sites=[SITE_DC0], + ip_addresses=list(ip_extra), + ) + + +def base_args(extra=()): + return ["--transit-cidr", TRANSIT, "--rack-ip", RACK, *extra] + + +os.environ["NETBOX_URL"] = "http://10.10.1.10:8000" # a known sandbox (guard passes) +os.environ["NETBOX_TOKEN"] = "fake-token" + +# ----------------------------------------------------------------------------- +# 1. DRY RUN is the default and writes NOTHING. +# ----------------------------------------------------------------------------- +fk = full_fake() +with captured_stdout() as out: + rc = run_main(base_args(), fk) +check(rc == 0, "dry run rc==0", str(rc)) +check(out.getvalue().count("would CREATE") == 2, "dry run PLANS both objects", + str(out.getvalue().count("would CREATE"))) +check(out.getvalue().count("CREATED ") == 0, "dry run CREATES nothing (no CREATED line)") +check("DRY RUN" in out.getvalue(), "dry run says so, loudly") +check(len(fk.creates) == 0, "dry run left the NetBox untouched (0 writes)", str(len(fk.creates))) + +# ----------------------------------------------------------------------------- +# 2. --commit writes exactly the transit prefix + the rack IP, correctly bound. +# ----------------------------------------------------------------------------- +fk = full_fake() +with captured_stdout(): + rc = run_main(base_args(["--commit"]), fk) +check(rc == 0, "commit rc==0", str(rc)) +check(len(fk.creates) == 2, "commit created exactly 2 objects", str(len(fk.creates))) +paths = [p for p, _ in fk.creates] +check(paths.count("ipam/prefixes") == 1, "commit wrote exactly one ipam/prefixes") +check(paths.count("ipam/ip-addresses") == 1, "commit wrote exactly one ipam/ip-addresses") +check("ipam/roles" not in paths and "dcim/sites" not in paths, + "commit created NO role / site (those are preconditions)") +pfx = next(pl for pp, pl in fk.creates if pp == "ipam/prefixes") +ipa = next(pl for pp, pl in fk.creates if pp == "ipam/ip-addresses") +check(pfx.get("prefix") == TRANSIT, "transit prefix is the supplied CIDR", str(pfx.get("prefix"))) +check(pfx.get("role") == 7, "transit prefix carries the transit role id (7)", str(pfx.get("role"))) +check(pfx.get("scope_type") == "dcim.site", "transit prefix is dcim.site-scoped", + str(pfx.get("scope_type"))) +check(pfx.get("scope_id") == 20, "transit prefix binds vr1-dc0's site id (20)", str(pfx.get("scope_id"))) +check(pfx.get("status") == "active", "transit prefix is status active") +check(ipa.get("address") == RACK_ADDR, "rack IP stored with the /22 metal-admin mask", + str(ipa.get("address"))) +check("role" not in ipa, "rack IP carries NO ipam role (NetBox ip role is a choice field)") +check(ipa.get("dns_name") == "vvr1-dc0", "rack IP dns_name identifies the rack controller vvr1-dc0", + str(ipa.get("dns_name"))) +check("rack controller" in ipa.get("description", "").lower(), + "rack IP description notes it is the vr1-dc0 MAAS rack controller") +check(ipa.get("status") == "active", "rack IP is status active") + +# ----------------------------------------------------------------------------- +# 3. A /31 transit is also accepted (point-to-point). +# ----------------------------------------------------------------------------- +fk = full_fake() +with captured_stdout(): + rc = run_main(["--transit-cidr", TRANSIT31, "--rack-ip", RACK, "--commit"], fk) +check(rc == 0 and len(fk.creates) == 2, "a /31 transit is accepted and writes both objects", + str((rc, len(fk.creates)))) + +# ----------------------------------------------------------------------------- +# 4. Already-present = idempotent SKIP (both present -> 0 creates, exit clean). +# ----------------------------------------------------------------------------- +fk = full_fake(prefixes_extra=[{"prefix": TRANSIT, "id": 30}], + ip_extra=[{"address": RACK_ADDR, "id": 31}]) +with captured_stdout() as out: + rc = run_main(base_args(["--commit"]), fk) +check(rc == 0, "both-present idempotent run rc==0") +check(out.getvalue().count("EXISTS") == 2, "both-present run reports both as EXISTS") +check(len(fk.creates) == 0, "both-present run creates nothing new") + +# ----------------------------------------------------------------------------- +# 5. Partial-present: transit already there, rack IP absent -> creates ONLY the rack IP. +# ----------------------------------------------------------------------------- +fk = full_fake(prefixes_extra=[{"prefix": TRANSIT, "id": 30}]) +with captured_stdout(): + rc = run_main(base_args(["--commit"]), fk) +check(rc == 0 and len(fk.creates) == 1 and fk.creates[0][0] == "ipam/ip-addresses", + "transit-present run creates ONLY the missing rack IP", + str([p for p, _ in fk.creates])) + +# 5b. Partial-present the other way: rack IP present, transit absent -> creates ONLY transit. +fk = full_fake(ip_extra=[{"address": RACK_ADDR, "id": 31}]) +with captured_stdout(): + rc = run_main(base_args(["--commit"]), fk) +check(rc == 0 and len(fk.creates) == 1 and fk.creates[0][0] == "ipam/prefixes", + "rack-IP-present run creates ONLY the missing transit prefix", + str([p for p, _ in fk.creates])) + +# ----------------------------------------------------------------------------- +# 6. HALF-WRITE GUARD -- a bad rack IP (the .1 gateway) aborts with ZERO creates, +# even though the transit prefix itself is perfectly valid. The transit must NOT +# be written before the rack-IP check fails. +# ----------------------------------------------------------------------------- +fk = full_fake() +run_dies("half-write: rack IP == .1 gateway is REJECTED before any write", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.1", "--commit"], fk) +check(len(fk.creates) == 0, "half-write: the rejected run wrote NOTHING (transit not created)") + +# ----------------------------------------------------------------------------- +# 7. Missing infra = die (each precondition), each writing nothing. +# ----------------------------------------------------------------------------- +fk = fake_netbox.FakeNB(roles=[], prefixes=[CONTAINER], sites=[SITE_DC0]) +run_dies("missing transit role is REJECTED", base_args(["--commit"]), fk) +check(len(fk.creates) == 0, "missing-role run wrote nothing") + +fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[], sites=[SITE_DC0]) +run_dies("missing Cloud container 10.12.0.0/16 is REJECTED", base_args(["--commit"]), fk) +check(len(fk.creates) == 0, "missing-container run wrote nothing") + +fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[CONTAINER], sites=[]) +run_dies("missing vr1-dc0 site is REJECTED", base_args(["--commit"]), fk) +check(len(fk.creates) == 0, "missing-site run wrote nothing") + +# ----------------------------------------------------------------------------- +# 8. Transit CIDR shape / placement rejects. +# ----------------------------------------------------------------------------- +run_dies("a transit OUTSIDE the Cloud container is REJECTED", + ["--transit-cidr", "192.168.0.0/30", "--rack-ip", RACK, "--commit"], full_fake()) +run_dies("a /29 transit is REJECTED (not point-to-point)", + ["--transit-cidr", "10.12.60.0/29", "--rack-ip", RACK, "--commit"], full_fake()) +run_dies("a /32 transit is REJECTED", + ["--transit-cidr", "10.12.60.1/32", "--rack-ip", RACK, "--commit"], full_fake()) +run_dies("a host-bits-set transit (10.12.60.1/30) is REJECTED", + ["--transit-cidr", "10.12.60.1/30", "--rack-ip", RACK, "--commit"], full_fake()) +run_dies("a non-CIDR transit is REJECTED", + ["--transit-cidr", "not-a-cidr", "--rack-ip", RACK, "--commit"], full_fake()) +# each of the above must have written nothing -- prove it once with a fresh fake +_fk = full_fake() +run_dies("bad transit writes nothing (proof)", + ["--transit-cidr", "10.12.60.0/29", "--rack-ip", RACK, "--commit"], _fk) +check(len(_fk.creates) == 0, "the rejected bad-transit run wrote NOTHING") + +# ----------------------------------------------------------------------------- +# 9. Rack-IP band rejects. +# ----------------------------------------------------------------------------- +run_dies("rack IP OUTSIDE metal-admin 10.12.8.0/22 is REJECTED", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.13.0.5", "--commit"], full_fake()) +run_dies("rack IP == .1 gateway is REJECTED", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.1", "--commit"], full_fake()) +run_dies("rack IP == .0 network address is REJECTED", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.0", "--commit"], full_fake()) +run_dies("rack IP in-/22-but-above-static-band (.50) is REJECTED", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.50", "--commit"], full_fake()) +run_dies("rack IP in-/22-but-in-a-higher-/24 (10.12.9.5) is REJECTED", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.9.5", "--commit"], full_fake()) +run_dies("rack IP with a non-/22 mask is REJECTED", + ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.5/24", "--commit"], full_fake()) +# accepted: the boundaries of the static band (.2 low, .49 high) both write. +for edge_ip in ("10.12.8.2", "10.12.8.49"): + fk = full_fake() + with captured_stdout(): + rc = run_main(["--transit-cidr", TRANSIT, "--rack-ip", edge_ip, "--commit"], fk) + check(rc == 0 and len(fk.creates) == 2, f"rack IP band edge {edge_ip} is ACCEPTED", + str((rc, len(fk.creates)))) + +# ----------------------------------------------------------------------------- +# 10. NO INVENTED LITERAL -- missing input fails loud (does not guess a value). +# ----------------------------------------------------------------------------- +run_dies("missing --transit-cidr fails loud", ["--rack-ip", RACK], full_fake()) +run_dies("missing --rack-ip fails loud", ["--transit-cidr", TRANSIT], full_fake()) + +# env fallback works (args-or-env): both via env, no flags. +os.environ["TRANSIT_CIDR"] = TRANSIT +os.environ["RACK_IP"] = RACK +fk = full_fake() +with captured_stdout(): + rc = run_main(["--commit"], fk) +check(rc == 0 and len(fk.creates) == 2, "env TRANSIT_CIDR/RACK_IP are honored (args-or-env)", + str((rc, len(fk.creates)))) +del os.environ["TRANSIT_CIDR"] +del os.environ["RACK_IP"] + +# ----------------------------------------------------------------------------- +# 11. Missing NETBOX_URL/TOKEN fails loud (does not guess a target). +# ----------------------------------------------------------------------------- +_u = os.environ.pop("NETBOX_URL", None) +run_dies("missing NETBOX_URL fails loud", base_args(), full_fake()) +os.environ["NETBOX_URL"] = _u + +# ----------------------------------------------------------------------------- +# 12. UPSTREAM-WRITE guard: --commit to a non-sandbox host refuses without the flag, +# proceeds with it. Sandbox needs no flag (covered above). +# ----------------------------------------------------------------------------- +os.environ["NETBOX_URL"] = "https://netbox.baldurkeep.com" # the production apex +fk = full_fake() +run_dies("upstream --commit WITHOUT --yes-write-upstream is REFUSED", base_args(["--commit"]), fk) +check(len(fk.creates) == 0, "the refused upstream --commit wrote NOTHING") + +fk = full_fake() +with captured_stdout(): + rc = run_main(base_args(["--commit", "--yes-write-upstream"]), fk) +check(rc == 0 and len(fk.creates) == 2, + "upstream --commit --yes-write-upstream IS allowed and writes both objects", + str((rc, len(fk.creates)))) +# a DRY upstream run needs no flag and writes nothing (the guard is a --commit gate). +fk = full_fake() +with captured_stdout(): + rc = run_main(base_args(), fk) +check(rc == 0 and len(fk.creates) == 0, "a DRY upstream run is fine and writes nothing") +os.environ["NETBOX_URL"] = "http://10.10.1.10:8000" + +# ----------------------------------------------------------------------------- +# 13. Structural pins (a changed constant a behavioral test alone would miss). +# ----------------------------------------------------------------------------- +check(T.ROLE_SLUG == "transit", "ROLE_SLUG is 'transit'") +check(T.CONTAINER == "10.12.0.0/16", "CONTAINER is 10.12.0.0/16 (Cloud)") +check(T.SITE_SLUG == "vr1-dc0", "SITE_SLUG is vr1-dc0 (transit site scope)") +check(T.METAL_ADMIN == "10.12.8.0/22", "METAL_ADMIN is 10.12.8.0/22") +check(T.STATIC_BAND_LOW == 2 and T.STATIC_BAND_HIGH == 49, + "static band is .2-.49 (D-120)") +check(T.STATUS == "active", "STATUS is active") +check(T.RACK_DNS == "vvr1-dc0", "RACK_DNS is vvr1-dc0") +check(T.SANDBOX_HOSTS == {"localhost", "127.0.0.1", "10.10.1.10"}, + "SANDBOX_HOSTS matches the sibling importers") +check("get_nb" in dir(T) and callable(T.get_nb), "get_nb() injection seam exists") +_src = inspect.getsource(T) +check('User-Agent"' in _src and "curl/8.5.0" in _src, "UA-aware (WAF-safe User-Agent set)") + +print() +if F == 0: + print(f"test_logic: ALL PASS ({P} checks)") + sys.exit(0) +print(f"test_logic: {F} FAIL of {P + F}") +sys.exit(1) diff --git a/tests/site-headend-install/run-tests.sh b/tests/site-headend-install/run-tests.sh index 4aa5c18..c954a6a 100644 --- a/tests/site-headend-install/run-tests.sh +++ b/tests/site-headend-install/run-tests.sh @@ -68,6 +68,36 @@ # has no egress, and the first apt install on it hangs. Cost a redeploy on 2026-07-13. grep -q 'subnet update .*gateway_ip' "$S" && ok || bad "trap 5 LOST: gateway_ip is never set on the compose subnet -- deployed machines will have NO default route" +# 6. RACK ROLE (D-123: ONE region on Office1 + a rack controller per DC). --role rack enrolls +# to the EXISTING region and must SKIP every region+rack concern (DB/LXD/compose). These +# tests are the point of the rack mode: a regression that quietly ran a region step, or that +# leaked the enrollment secret, would not surface until a live DC build. +RURL="http://10.10.0.20:5240/MAAS" +t "bad --role -> 2" 2 bash "$S" --role bogus --dry-run +# rack REQUIRES --region-url + --enroll-secret-file (missing -> 2), mirroring --compose-cidr. +t "rack missing --region-url -> 2" 2 bash "$S" --role rack --dry-run --enroll-secret-file /nonexistent +t "rack missing --enroll-secret-file -> 2" 2 bash "$S" --role rack --dry-run --region-url "$RURL" +t "rack bad --region-url -> 2" 2 bash "$S" --role rack --dry-run --region-url ftp://x --enroll-secret-file /nonexistent +# --compose-cidr is NOT required in rack mode (LXD-compose is a region concern). +SECFILE="$(mktemp)"; SENTINEL="RACK_ENROLL_SENTINEL_9x7"; printf '%s\n' "$SENTINEL" > "$SECFILE" +trap 'rm -f "$SECFILE"' EXIT +t "rack --dry-run (no --compose-cidr) -> 0" 0 bash "$S" --role rack --dry-run --region-url "$RURL" --enroll-secret-file "$SECFILE" + +rackout="$(bash "$S" --role rack --dry-run --region-url "$RURL" --enroll-secret-file "$SECFILE" 2>&1)" +printf '%s' "$rackout" | grep -q 'maas init rack' && ok || bad "rack --dry-run must print the 'maas init rack' step" +# The enrollment secret must NEVER appear in output (read from the file at run time, never echoed). +printf '%s' "$rackout" | grep -q "$SENTINEL" && bad "rack --dry-run LEAKED the enrollment secret into output" || ok +# rack mode must NOT run any region+rack / LXD / compose step. +for m in 'lxdbr0' 'region+rack' 'compose' 'vm-host'; do + printf '%s' "$rackout" | grep -qi "$m" && bad "rack --dry-run printed a region+rack-only step: '$m'" || ok +done + +# 7. BACKWARD COMPAT: with no --role, the default is region+rack and --compose-cidr is still +# REQUIRED (a rack-mode regression must not relax the legacy contract). +t "default role still needs --compose-cidr -> 2" 2 bash "$S" --dry-run +defout="$(bash "$S" --dry-run --compose-cidr 10.10.1.0/24 2>&1)" +printf '%s' "$defout" | grep -q 'region+rack' && ok || bad "default (no --role) must still run the region+rack flow" + echo total=$((pass+fail)) if [ "$fail" -eq 0 ]; then echo "site-headend-install: $pass/$total PASS"; exit 0; fi