diff --git a/docs/changelog-20260714-d119-region-qualified-dc-namespace.md b/docs/changelog-20260714-d119-region-qualified-dc-namespace.md new file mode 100644 index 0000000..8d06fe3 --- /dev/null +++ b/docs/changelog-20260714-d119-region-qualified-dc-namespace.md @@ -0,0 +1,108 @@ +# 2026-07-14 -- D-119: region-qualify the VR1 DC namespace (Stage 2 close-out C3 / gap #19) + +Closes the collision that BLOCKED Stage 3. **D-119 COMPLETES D-117; it does not reverse it.** + +## The bug, in one line + +The token `dc0` meant **two different clouds** depending on which file you were reading: VR0's LIVE +testcloud in `scripts/lib-net.sh`, and VR1's FIRST DC in the NetBox importer. One string, two +clouds, one of them in production. + +## The ruling (operator, 2026-07-14) + +The repo adopts the apex's names verbatim, region-qualified: + + vr0-dc0 VR0's DC0 the LIVE testcloud (a DIFFERENT region) + vr1-dc0 VR1's FIRST DC GUA 2602:f3e2:f02::/48 + vr1-dc1 VR1's SECOND DC GUA 2602:f3e2:f03::/48 + +Bare `dc0`/`dc1`/`dc2` are now **REJECTED LOUDLY** everywhere. Accepting them "for compatibility" +would preserve the exact ambiguity being deleted. + +**NO PRODUCTION IPAM WRITE.** The apex was already correct and self-consistent; the REPO was the +only surface out of step. MEASURED against `netbox.baldurkeep.com` before any edit: + + 2602:f3e2:f02::/48 -> site vr1-dc0 "Virtual Region 1 (VR1) Datacenter 0 (DC0)" + 2602:f3e2:f03::/48 -> site vr1-dc1 "Virtual Region 1 (VR1) Datacenter 1 (DC1)" + +The alternative (rename the apex to match the repo's `dc1`/`dc2`) was considered and REJECTED: it +needed a production write on the IPAM authority, made VR1 the only 1-indexed region, and would have +created a worse ambiguity (`vr1-dc1` = VR1's FIRST DC vs `vr0-dc1` = VR0's SECOND). See D-119. + +## The prize: the off-by-one becomes structurally impossible + +The importer's DC->site map is now an **IDENTITY** (`vr1-dc0` -> slug `vr1-dc0`). There is no offset +table left to get wrong -- and the original defect was precisely a WRONG LOOKUP TABLE. An assert +enforces it: + + assert all(k == v["slug"] for k, v in SITES.items()) # D-119 + +## TWO REAL BUGS found by the review sweep, which the naming fix alone would NOT have closed + +**BREAK-1 -- corrupted descriptions.** The importer built labels with `f"VR1 {dc.upper()} ..."`. +Fine when `dc` was `"dc0"`; under D-119 `dc` is `"vr1-dc0"`, rendering **`"VR1 VR1-DC0 +provider-public"`** on all 36 prefixes. Same defect class as the original bug -- DERIVING a label by +munging a token instead of looking it up -- hiding in the description field, where slug-focused +review missed it. Now `f"{site_cfg['name']} ..."`, looked up. + +**BREAK-2 -- `DC_GUA_PREFIX` was never cross-checked against `--dc`.** THIS IS THE IMPORTANT ONE. +`--dc vr1-dc0 DC_GUA_PREFIX=` was **accepted** -- it writes the SECOND DC's GUA prefixes, +carved with the FIRST DC's ULA nibble (`DC_V6_INDEX` is keyed off `--dc`, independently), all scoped +to the FIRST DC's site. **A silently mis-bound datacenter, assembled from two disagreeing sources.** +Identity-mapping the site slug makes the *slug* unfalsifiable but leaves the *addressing* +free-floating -- so the naming fix alone would NOT have finished the job. Now guarded by +`EXPECTED_GUA`, which pins the apex's measured binding and refuses a mismatched pair. + +## What changed + +- `scripts/lib-net.sh`, `lib-hosts.sh` -- region-qualified selectors; bare `dcN` REJECTED. + `vr0-dc0` and `vr1-dc0` are SEPARATE case arms on purpose: they are behaviourally identical today + but semantically independent (`vr1-dc0` no-ops because D-101 says it INHERITS VR0 DC0's v4 + layout). The day D-101 stops holding, one diverges and the other must not. +- `netbox/dc-dc-prefixes-import.py` -- identity `SITES`, the `EXPECTED_GUA` guard, the description + fix, `DC1_V4_SUPERNET`/`DC2_V4_SUPERNET` -> **`VR1_DC1_V4_SUPERNET`** (BOTH old names rejected BY + NAME: an unqualified "DC1" could mean VR0's dc0 OR VR1's second DC). +- `scripts/dc-dc-{dr-drill,radosgw-multisite,rbd-mirror,ceph-disk-budget}.sh`, + `{phase-00-maas-standup,carve-host-interfaces,reenroll-hosts}.sh` -- selectors + `DC=` hints. + `rbd-mirror`'s `--site-name` aligned too: it is a DIFFERENT namespace that LOOKS identical, and + `--dc vr1-dc0 --site-name dc1` on one command line is a re-created collision. +- `opentofu/` -- `dc1_*` -> `vr1_dc0_*`, `dc2_*` -> `vr1_dc1_*`, with **`moved {}` blocks**. +- All 6 VR1 runbooks + `netbox/README.md` + `docs/vr1-office1-as-built.md` call sites. +- **D-114 amendment:** the DC containment VMs `vdc1`/`vdc2` -> **`vvr1-dc0`/`vvr1-dc1`**. Neither is + built, so this is free -- and D-114's own DR primitive is **`virsh destroy vdc1`**, which under the + old naming reads as "destroy VR1 DC1" while MEANING "destroy VR1 DC0". A mislabelled destroy + command in a DR drill is not cosmetic. `voffice1` KEEPS its name. + +## Tests + +`tests/dc-selector` 21 -> **30** (new: every bare `dcN` must be REJECTED). +`tests/dc-dc-prefixes-import` 40 -> **82** (new: the identity invariant, `EXPECTED_GUA` in BOTH +mismatch directions, that a mismatched run writes NOTHING, that the MATCHING pair still succeeds, +and that no description contains the munged `VR1 VR1-DC0`). +**GAUNTLET ALL GREEN (57 harnesses); repo-lint 0 fail.** + +The old harnesses were not merely stale -- they **pinned the wrong mapping**, and would have gone +green while enforcing the bug. That is the failure mode this change exists to kill. + +## The `tofu apply` is NOT done -- it is GATED + +`opentofu/` changes rename the libvirt objects (`dc_name`/`link_name` interpolate straight into each +object's `name`, which is ForceNew), so the plan REPLACES them. `moved {}` cannot suppress a +ForceNew -- it is there so the plan reads as 11 clean "must be replaced" lines instead of 11 +unrelated destroy+create pairs, i.e. so it is REVIEWABLE. + + Plan: 11 to add, 0 to change, 11 to destroy. + +**Safe, MEASURED before the rename:** all 11 objects are EMPTY -- no guests attached (Stage 3 has +not run, so no DC node VMs exist) and no volumes in either DC pool (the only volumes in state belong +to `office1_opnsense`, `ubuntu_noble_base`, `voffice1`, none of which sit in a DC pool). Office1's +live networks do not appear in the plan at all. + +**Not applied.** A destroying apply is operator-gated. Re-run `tofu plan` at execution and STOP if +anything shows as ATTACHED (standing lesson 2: "updated in-place" does NOT mean "no restart"). + +## Revert + + git revert + # opentofu: the moved{} blocks make the reverse direction symmetrical; re-plan before applying. + # No NetBox state to revert -- this change made ZERO writes to any NetBox. diff --git a/docs/dc-dc-deployment-workflow.md b/docs/dc-dc-deployment-workflow.md index 9dc91d8..a040c78 100644 --- a/docs/dc-dc-deployment-workflow.md +++ b/docs/dc-dc-deployment-workflow.md @@ -83,7 +83,7 @@ | **Build** | `voffice1` site containment VM (OpenTofu, `modules/cloudinit-vm` + `modules/base-image`, on `office1-local`, DHCP from the edge's Kea, `expose_nested_virt = true`); MAAS region controller ON `voffice1`; LXD ON `voffice1` (5.21 LTS track); that LXD registered into MAAS as an LXD KVM host; NetBox + Tailscale as MAAS-COMPOSED LXD VMs inside it (**GitBucket REMOVED from scope -- D-116**); Office1's own OPNsense simulated-ISP edge (gap #16, Stage 2 owns it). | | **Gate** | Office1 OPNsense edge up (**MET** -- routing, NAT, egress, Kea DHCP; gap #17 closed for Office1 via `office1-wan`); `office1-local` network created (gap #12 CLOSED); OpenTofu reaches vcloud host libvirt (**MET**); **`voffice1` exists and runs (MET 2026-07-13, measured)**; `voffice1` has a reserved address + SSH + nested KVM exposed; MAAS region reachable ON `voffice1`; LXD on the 5.21 track with `lxdbr0` OFF `office1-local`; LXD registered as a MAAS KVM host; **the non-stack machines COMPOSED BY MAAS and deployed -- i.e. an LXD VM actually BOOTS at L3, which is D-114's DC1 entry gate**; NetBox authoritative + populated (planes, per-DC v4, ULA/GUA carve); Tailscale confirmed to Office1 only. **GitBucket is NO LONGER A GATE (D-116): no Office1-local GitBucket is built; `git.baldurkeep.com` remains the git service of record.** | | **Owns** | **D-114** (site containment VM + MAAS-composed LXD VMs, incl. the LXD 5.21 pin), D-103 as AMENDED by D-114 (OpenTofu owns the containment VM and the site networks; MAAS composes INSIDE the site), D-107 (headend is not a core-service provider). | -| **Reuse vs new** | NEW infra (Office1 didn't exist for VR0 DC0), but LESS new than it looks: the MAAS-composed-LXD-VM half is **exactly what VR0 runs today** (its `lxd` KVM host at LXD 5.21.4, with `tailscale` composed into it at 2 cores / 2 GiB / 25 GB) and carries **NO Roosevelt delta**. The containment VM itself IS a deliberate simulation-only delta -- it is what makes a "hard-down an entire facility" DR drill (`virsh destroy vdc1`) executable at all. NetBox-as-IPAM-apex discipline and the `netbox/*-import.py` pattern transfer unchanged. | +| **Reuse vs new** | NEW infra (Office1 didn't exist for VR0 DC0), but LESS new than it looks: the MAAS-composed-LXD-VM half is **exactly what VR0 runs today** (its `lxd` KVM host at LXD 5.21.4, with `tailscale` composed into it at 2 cores / 2 GiB / 25 GB) and carries **NO Roosevelt delta**. The containment VM itself IS a deliberate simulation-only delta -- it is what makes a "hard-down an entire facility" DR drill (`virsh destroy vvr1-dc0`) executable at all. NetBox-as-IPAM-apex discipline and the `netbox/*-import.py` pattern transfer unchanged. | | **Authoring status** | **Runbook REWRITTEN 2026-07-13 to the D-114 model: `runbooks/dc-dc-phase1-office1-standup.md`.** The old Option A / Option B provisioning fork is **CLOSED** -- OpenTofu (Option A) is the path and `module "voffice1"` is a real, applied `cloudinit-vm` instantiation (real image source, real `user_data`/`meta_data`/`network_config`, plus the new `expose_nested_virt` variable). **PENDING VERIFICATION:** the MAAS/LXD install + LXD-KVM-host registration + VM-compose command lines are NOT yet confirmed against Canonical's current docs -- the runbook marks each unconfirmed flag/channel/subcommand rather than fabricating one, and a separate verification pass owns filling them in. | ### !!! STAGE 2 IS THE BRANCH MERGE POINT (operator ruling, 2026-07-14) !!! @@ -134,7 +134,7 @@ |---|---|---| | C1 | Office1 LAN IPv6 deployed (`2602:f3e2:f01:100::1/64` + RA on the edge LAN). WAN v6 leg DEFERRED: v6 does not egress the lab. | **DONE 2026-07-14** -- live and PROVEN ON THE WIRE: address on the kernel, radvd advertising the prefix (`mode=unmanaged`), `voffice1` autoconfigured `...:5054:ff:fe6a:87e5` by SLAAC, edge->voffice1 GUA ping 0.0% loss. NOTE the D-113 AMENDMENT: the ADDRESS half is **not** REST-API-doable (`scripts/opnsense-set-interface-v6.sh`); only the RA half is. `docs/changelog-20260714-office1-lan-ipv6-executed.md` | | C2 | The validated carve fed UPSTREAM to `netbox.baldurkeep.com` -- the gate says *authoritative*, and the sandbox is not it. Operator-gated write. | **OPEN** | -| C3 | The `$DC` shell-selector collision resolved -- `lib-net.sh`/`lib-hosts.sh` `dc0` means **VR0's** DC0 while the D-117 apex `dc0` means **VR1's**. D-117 Option B is only PARTIALLY executed. Blocks Stage 3 (see gap #19). | **OPEN** | +| C3 | The `$DC` shell-selector collision resolved. | **DONE 2026-07-14 (D-119)** -- selectors are REGION-QUALIFIED (`vr0-dc0`/`vr1-dc0`/`vr1-dc1`) across `lib-net.sh`, `lib-hosts.sh`, `opentofu/`, the importer and every runbook call site. Bare `dcN` is REJECTED. The importer's DC->site map is now an IDENTITY, so the off-by-one is structurally impossible. Gap #19 CLOSED. | | C4 | The NetBox sandbox loop documented here and in `docs/dc-dc-netbox-buildout-scope.md`. | **OPEN** | | C5 | This doc's Stage 2 row reconciled against reality (it has drifted three times). | **DONE 2026-07-14** | diff --git a/docs/design-decisions.md b/docs/design-decisions.md index b5700c3..dbb2d63 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -2612,6 +2612,14 @@ in a virtual rehearsal. The LXD half carries NO delta -- it is exactly what VR0 runs today and what Roosevelt would run on real hardware (a service host running LXD, registered to MAAS). +> **D-119 NAMING AMENDMENT (2026-07-14).** The DC containment VMs named `vdc1`/`vdc2` above are +> **RENAMED `vvr1-dc0` / `vvr1-dc1`** to match the region-qualified DC namespace. Neither is built +> yet (only `voffice1` exists), so this costs nothing -- and it removes a genuinely dangerous +> ambiguity: this decision's own DR primitive is **`virsh destroy vdc1`**, which under the old +> naming would read as "destroy VR1 DC1" while actually meaning "destroy VR1 DC0". A mislabelled +> destroy command in a DR drill is not cosmetic. `voffice1` KEEPS its name (the office keeps its +> number, per D-119). The `vdc1`/`vdc2` spellings in the body above are HISTORICAL. + ## D-115: VR1 Office1 addressing -- the office carve, and the v4 role gaps it exposed **Status:** **ADOPTED 2026-07-13 (operator ruling -- see the amendment at the end of this entry).** Originally PROPOSED/OPEN the same day. Found during Office1 deployment @@ -2826,10 +2834,20 @@ ## D-117: VR1 site naming -- the apex says DC0/DC1, the repo says DC1/DC2 (G5) -**Status:** **ADOPTED 2026-07-13 (operator ruling: Option B, DC-only).** The REPO moves to the -apex's 0-indexed convention **for the DATACENTERS**. NetBox is the IPAM apex and is NOT bent to fit -the repo. Supersedes the `docs/dc-dc-netbox-buildout-scope.md` G5 recommendation (which proposed the -opposite, Option A) and the D-106 `dc1`/`dc2` zone labels. Closes G5. +**Status:** **ADOPTED 2026-07-13 (operator ruling: Option B, DC-only). FULLY EXECUTED BY D-119 +(2026-07-14) -- the "PARTIALLY EXECUTED" caveat below is DISCHARGED; read D-119 for the end state.** +The REPO moves to the apex's 0-indexed convention **for the DATACENTERS**. NetBox is the IPAM apex +and is NOT bent to fit the repo. Supersedes the `docs/dc-dc-netbox-buildout-scope.md` G5 +recommendation (which proposed the opposite, Option A) and the D-106 `dc1`/`dc2` zone labels. +Closes G5. + +> **D-119 completed this decision, it did not reverse it.** D-117 renamed only the NetBox IMPORT +> PATH, which left `dc0` meaning VR1's first DC in the importer and **VR0's live cloud** in +> `lib-net.sh` -- one string, two clouds. D-119 executes this decision's own amendment +> (region-qualify the selectors: `vr0-dc0` / `vr1-dc0` / `vr1-dc1`) across `lib-net.sh`, +> `lib-hosts.sh` and `opentofu/`, making the importer's DC->site mapping an IDENTITY. **The +> alternative -- renaming the apex to match the repo's `dc1`/`dc2` -- was considered and REJECTED; +> see D-119.** The apex is NOT renamed and no production IPAM write is involved. **Operator refinement (verbatim):** *"go ahead and rename the DC numbers. The office number is fine."* -- so the **OFFICE KEEPS ITS NUMBER**: it stays **Office1**, and its NetBox site is @@ -3038,3 +3056,99 @@ (`DC_GUA_PREFIX` values are MEASURED off the apex per D-117; `DC1_V4_SUPERNET` is assigned by D-115.) Per D-101/D-111 each DC takes a `/56` of this `/48`, indexed to its GUA site nibble, so the 4th hextet reads `DC.NN` in BOTH families. + +## D-119: the VR1 DC namespace -- region-qualify the selectors; the repo adopts the apex + +**Status:** **ADOPTED 2026-07-14 (operator ruling).** COMPLETES D-117 rather than reversing it: +it executes D-117's own amendment (region-qualify the shell selectors) across the three surfaces +D-117 never touched. D-117 stays ADOPTED; its "partially executed" caveat is discharged here. +Closes tooling-gap #19 and Stage 2 close-out item C3. + +### The problem, stated exactly + +After D-117, the token `dc0` meant TWO DIFFERENT CLOUDS depending on which file you were reading: + +| Surface | `dc0` meant | `dc1` meant | +|---|---|---| +| NetBox apex (the IPAM authority) | VR1's FIRST DC | VR1's SECOND DC | +| `scripts/lib-net.sh` / `lib-hosts.sh` | **VR0's DC0 -- a DIFFERENT, LIVE cloud** | VR1's first DC | +| `opentofu/main.tf` | (no `dc0`) | VR1's first DC | + +One string, two clouds, one of them in production. A mechanical renumber makes it WORSE, not +better (D-117 TRAP 1): `sed s/dc1/dc0/` over `opentofu/` would hand VR1's first DC the string +`dc0`, which `lib-net.sh` already resolves to VR0's deployed literals. + +### The ruling + +**Region-qualify the DC selector everywhere. The repo adopts the apex's names verbatim.** + + vr0-dc0 VR0's DC0 the LIVE testcloud (a different region) + vr1-dc0 VR1's FIRST DC GUA 2602:f3e2:f02::/48 + vr1-dc1 VR1's SECOND DC GUA 2602:f3e2:f03::/48 + +Bare `dc0` / `dc1` / `dc2` are REJECTED loudly by the selectors. They are the ambiguity being +deleted; accepting them "for compatibility" would preserve the bug. + +**The apex is NOT renamed. There is NO production IPAM write in this decision.** The apex was +already self-consistent and 0-indexed; MEASURED 2026-07-14 against `netbox.baldurkeep.com`: + + 2602:f3e2:f02::/48 -> site vr1-dc0 "Virtual Region 1 (VR1) Datacenter 0 (DC0)" + 2602:f3e2:f03::/48 -> site vr1-dc1 "Virtual Region 1 (VR1) Datacenter 1 (DC1)" + (VR0, for contrast: vr0-dc0, vr0-dc1, vr0-off0 -- the same 0-indexed convention) + +The repo was the only surface out of step, because -- operator, verbatim in substance -- *"we +didn't catch the dc0 missing and went straight to one."* + +### Why this beats the alternative (renaming NetBox to match the repo) + +Considered and REJECTED: rename the apex to `vr1-dc1`/`vr1-dc2` so the ordinals match the repo's +`dc1`/`dc2`. It was rejected because it would: +- require a PRODUCTION IPAM write on the authority, ordered highest-slug-first or it violates + NetBox's unique-slug constraint; +- make VR1 the ONLY 1-indexed region, diverging from VR0 -- the very region VR1 rehearses; and +- create a worse ambiguity: `vr1-dc1` would mean VR1's FIRST DC while `vr0-dc1` still means VR0's + SECOND DC. Same suffix, different ordinal, one NetBox. + +(For the record, the apex is NOT uniformly `dcN`: the Eugene/Roosevelt sites are place-named -- +Charnelton, Cleveland, Roosevelt, Stevenson, Willamette. The `dcN` convention belongs to the +REHEARSAL regions VR0/VR1, and within those it is 0-indexed. The consistency claim is scoped to +them, not to the whole apex.) + +### The prize: the off-by-one becomes structurally impossible + +The importer's DC->site mapping becomes an IDENTITY (`vr1-dc0` -> slug `vr1-dc0`). **There is no +offset table left to get wrong.** This matters because the original defect was precisely a WRONG +LOOKUP TABLE -- `--dc dc1` bound to slug `vr1-dc1` while treating `f02::/48` as dc1's, though the +apex binds `f02` to VR1 DC0. It would have written the FIRST DC's prefixes onto the SECOND DC's +site, silently. Deleting the mapping deletes the bug class. + +### Scope -- what this decision does NOT change + +- **The office KEEPS its number** (operator ruling): NetBox site `vr1-off1`, and `Office1` / + `voffice1` / `office1-local` / `office1-netbox` / `~/vr1-office1-creds` throughout. The site is + BUILT AND RUNNING; renaming it churns hostnames, creds paths and libvirt networks for zero + functional gain. This leaves a known, ACCEPTED asymmetry with VR0's `vr0-off0`. Documented, not + fixed. +- **VR0 is not renamed.** Its DC stays DC0; only the SELECTOR STRING is region-qualified + (`dc0` -> `vr0-dc0`). No VR0 literal, host, or deployed object changes. +- **The ULA hextet still mirrors the GUA site nibble, NOT the DC number** (D-111): `vr1-dc0` -> + `fd50:840e:74e2:2NN::`, `vr1-dc1` -> `:3NN::`, tracking `f02`/`f03`. This is DELIBERATE so the + 4th hextet reads the same in both families. Do not "fix" it to match the DC index -- that would + break the already-imported sandbox prefixes. +- **NetBox data is untouched.** No re-import, no re-dump of `netbox/draft/vr1-draft.json`. + +### Blast radius (MEASURED 2026-07-14, before any edit) + +`opentofu/` renames change the libvirt objects' `name` attribute, so `tofu apply` will +DESTROY AND RECREATE them. Measured: the 6 `dc1_planes` networks, both DC storage pools, and the +3 mesh links are live in state but **EMPTY** -- no guests attached (Stage 3 has not run) and no +volumes in the DC pools (the only volumes belong to `office1_opnsense`, `ubuntu_noble_base`, and +`voffice1`, none of which are in a DC pool). The recreate is therefore safe, but it IS a +destroying apply and is gated: the plan is presented before it runs. + +### Standing rule this leaves behind + +**Never write a bare `dcN` for a VR1 datacenter again, in code or in prose.** Region-qualify it. +The one durable lesson of D-117 -- which survives its completion -- is that an unqualified `dcN` +in a repo that operates more than one cloud is a latent off-by-one waiting for a reader who does +not know which cloud the sentence is about. diff --git a/docs/session-ledger.md b/docs/session-ledger.md index d5b2e53..ae6ef3f 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -198,7 +198,24 @@ All are IMPORTED and fidelity-checked in the sandbox. **CAUTION -- `DC2_V4_SUPERNET` IS A RETIRED NAME (D-117).** The importer now hard-rejects it and rejects `--dc dc2`. Any doc still saying `10.13.0.0/19` is pre-D-115 and wrong. -6. **G5 NetBox site rename -- CLOSED as D-117 (ADOPTED, Option B).** The repo moved to the apex's +6. **D-119 (ADOPTED 2026-07-14) -- THE DC NAMESPACE IS REGION-QUALIFIED. C3/gap #19 CLOSED.** + `vr0-dc0` (VR0's LIVE testcloud) / `vr1-dc0` (VR1's FIRST DC, GUA f02::/48) / `vr1-dc1` (VR1's + SECOND, f03::/48). Bare `dcN` is REJECTED everywhere -- it meant VR0's live cloud in `lib-net.sh` + but VR1's first DC in the importer: one string, two clouds. D-119 **COMPLETES D-117** (executes + its own amendment); it does not reverse it. **ZERO NetBox writes** -- the apex was already right; + the REPO was the odd one out. The importer's DC->site map is now an IDENTITY, so the off-by-one is + STRUCTURALLY IMPOSSIBLE, not merely defended. Also renamed: `vdc1`/`vdc2` -> `vvr1-dc0`/`vvr1-dc1` + (D-114's own DR primitive is `virsh destroy vdc1` -- it read as "destroy VR1 DC1" while MEANING + "destroy VR1 DC0"). Office KEEPS its number (`vr1-off1`, `Office1`, `voffice1`). + Env var: `DC1_/DC2_V4_SUPERNET` -> **`VR1_DC1_V4_SUPERNET`** (both old names rejected by name). + Gauntlet ALL GREEN (57); dc-selector 21->30, prefixes-import 40->82. + **THE `tofu apply` IS NOT DONE -- IT IS GATED.** Plan: 11 to add, 0 to change, 11 to destroy (the + libvirt object NAMES change, which is ForceNew; `moved{}` makes the plan reviewable but cannot + suppress a replace). MEASURED SAFE: all 11 are EMPTY (no guests, no volumes; Stage 3 hasn't run). + Re-plan at execution and STOP if anything shows ATTACHED. + See `docs/changelog-20260714-d119-region-qualified-dc-namespace.md`. + +6a. **(historical) G5 NetBox site rename -- CLOSED as D-117 (ADOPTED, Option B).** The repo moved to the apex's 0-indexed convention (first VR1 DC = `vr1-dc0`), rather than bending the apex to the repo. This fixed a REAL off-by-one: `dc-dc-prefixes-import.py` bound `--dc dc1` to slug `vr1-dc1` while treating `f02::/48` as dc1 -- but the apex binds `f02` to `VR1 DC0`. It would have written the @@ -266,6 +283,39 @@ 10. **v1-close checklist is QUEUED** (handoff section 6) -- gated on D-011 item 6, which is gated on the SEC-003 unseal rehearsal. +## OPEN -- NetBox WRITE-PATH BUGS (found 2026-07-14 by adversarial review; LOGGED, NOT FIXED) + +These are independent of D-119's naming work. **They matter BEFORE C2** (feeding the carve upstream), +because they are what would corrupt the production apex during that write. + +1. **`roles-aggregates-import.py` still dies HALF-WAY through a production write.** The preflight it + advertises only checks role-NAME collisions. The `ARIN` RIR lookup and `validate_ula_48()` both run + **after** the role-creation loop has already committed 5 roles. A typo'd `ORG_ULA_48`, or an apex + without ARIN, writes 7 objects then dies with **no rollback**. The RIR preflight loop is a literal + no-op (`if ...: continue` + a comment). The harness cannot catch it: it only asserts the STRING + "PREFLIGHT" appears before the first create. **This is the same bug class that already bit us once.** +2. **`sandbox-fidelity-check.py` can return a FALSE GREEN.** It field-compares only the SHARED objects; + the planned delta is checked merely as a SUBSET of `EXPECTED_NEW`. So a prefix created with the + WRONG scope/role/status passes clean -- and if the importer created ZERO of the 36 DC prefixes, + `extra subset-of expected` still holds and it exits 0 saying "nothing lost, nothing stray." + **We have been citing this script as proof the sandbox is faithful. It cannot prove delta + correctness.** It WOULD have caught the historical 17-prefix scope drop (those were shared objects). +3. **`find_role()` dies mid-loop** in `dc-dc-prefixes-import.py` -- roles are resolved lazily INSIDE + the write loop. Against upstream as it stands today (no six-plane roles), a `--commit` creates the + site + 4 prefixes then dies on the 5th. No whole-plan preflight. +4. **Same-dumper blind spot:** `sandbox-fidelity-check` compares two dumps produced by the SAME field + list, so any field the dumper omits is invisible on BOTH sides by construction (prefix `vrf`, + `tenant`, `vlan`, `tags`, `custom_fields`; site `tenant`/`group`/`facility`). Structurally the same + shape as the region-scope bug that dropped 17 prefixes. +5. **Duplicate-CIDR collapse:** prefix-keyed dicts in the seeder and the fidelity check collapse + duplicate prefixes (NetBox permits them, and the importer's own docstring anticipates them vs + `vr0-dc0`). A whole prefix object can vanish with a green check. Latent -- no duplicates upstream today. +6. **The test fake diverges from reality at the failure point:** `fake_pynetbox.get()` returns + `matches[0]` on a multi-match where REAL pynetbox RAISES. So the harness cannot catch #5. + +**Recommended sequence: fix 1-3 BEFORE C2.** 1 and 3 are partial-write-no-rollback on the production +IPAM; 2 is what we would use to prove the write was correct. + ## Operator-gated / PINNED (do NOT re-ask; these are rulings, not open questions) - **BRANCH MERGE POINT = STAGE 2 CLOSE (operator ruling 2026-07-14).** Branch diff --git a/docs/vr1-office1-as-built.md b/docs/vr1-office1-as-built.md index 8793a7d..9880ae5 100644 --- a/docs/vr1-office1-as-built.md +++ b/docs/vr1-office1-as-built.md @@ -155,7 +155,7 @@ renumbering it -- the carve cost ZERO renumber. What it fixed is that `10.10.0.0/24`, `10.10.1.0/24` and `172.30.1.0/24` were **SQUAT**: on the wire with no allocation behind them. -**Naming (D-117):** VR1's datacenters are **DC0/DC1** (matching the apex, which already held both +**Naming (D-117 + D-119):** VR1's datacenters are **vr1-dc0** (first) and **vr1-dc1** (second) -- the selector IS the NetBox apex slug. Bare `dcN` is RETIRED (it meant VR0's live cloud in `lib-net.sh`). The office KEEPS its number: **Office1** / `vr1-off1` (matching the apex, which already held both sites) but **the office keeps its number** -- it is **Off1**. VR1 therefore reads DC0/DC1/**Off1** while VR0 reads DC0/DC1/Off0. Accepted cosmetic asymmetry; the site is NEW upstream so nothing was renamed and **no deployed object changed**. diff --git a/netbox/README.md b/netbox/README.md index 0e8d52e..6e27e2e 100644 --- a/netbox/README.md +++ b/netbox/README.md @@ -9,7 +9,7 @@ ### `dc-dc-prefixes-import.py` Extends the v1 single-site IPv4-only pattern to VR1's two-DC, dual-stack -model (D-101, `docs/design-decisions.md`). Per DC (`--dc dc1` or `--dc dc2`): +model (D-101, `docs/design-decisions.md`). Per DC (`--dc vr1-dc0` or `--dc vr1-dc1`): creates the six-plane IPv4 structure plus the D-101 family-matrix IPv6 legs (ULA for metal-admin/metal-internal/data-tenant/storage/replication, GUA for provider-public). @@ -18,7 +18,7 @@ explicitly "NOT hardcoded in this decision": the org ULA /48, the per-DC GUA carve out of the real ARIN 2602:f3e2::/32 block, and DC2's v4 supernet. All three are REQUIRED environment variables with no defaults -- -`ORG_ULA_48`, `DC_GUA_PREFIX`, and (for `--dc dc2` only) `DC2_V4_SUPERNET`. +`ORG_ULA_48`, `DC_GUA_PREFIX`, and (for `--dc vr1-dc1` only) `VR1_DC1_V4_SUPERNET`. The script validates format/containment and FAILS LOUD if any is missing or malformed; it never silently substitutes DC1's values for DC2 or invents a /48. DC1's v4 planes ARE hardcoded here, deliberately -- D-101 rules DC1 @@ -34,12 +34,12 @@ ``` NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN= \ ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:1000::/40 \ - python3 dc-dc-prefixes-import.py --dc dc1 + python3 dc-dc-prefixes-import.py --dc vr1-dc0 NETBOX_URL=... NETBOX_TOKEN=... \ ORG_ULA_48=fd00:1234:5678::/48 DC_GUA_PREFIX=2602:f3e2:2000::/40 \ - DC2_V4_SUPERNET=10.13.0.0/19 \ - python3 dc-dc-prefixes-import.py --dc dc2 + VR1_DC1_V4_SUPERNET=10.13.0.0/19 \ + python3 dc-dc-prefixes-import.py --dc vr1-dc1 ``` **Role slugs used here are NEW** (`provider-public`/`metal-admin`/ diff --git a/netbox/dc-dc-prefixes-import.py b/netbox/dc-dc-prefixes-import.py index ad98905..e4f81c7 100644 --- a/netbox/dc-dc-prefixes-import.py +++ b/netbox/dc-dc-prefixes-import.py @@ -6,8 +6,8 @@ extends the v1 (netbox/ipv4-prefixes-import.py) single-site IPv4-only import to the VR1 two-DC, dual-stack model ratified in D-101 (docs/design-decisions.md). -Per DC (dc0 or dc1, selected via --dc, mirroring the scripts/lib-net.sh -$DC selector convention, DOCFIX-151), creates the six-plane structure with +Per DC (vr1-dc0 or vr1-dc1, selected via --dc, mirroring the scripts/lib-net.sh +$DC selector convention, DOCFIX-151/D-119), creates the six-plane structure with the D-101 family matrix: Plane v4 v6 family @@ -18,13 +18,17 @@ storage (see below) ULA-only replication (see below) ULA-only -NAMING (D-117, 2026-07-13): VR1's DCs are **VR1 DC0** and **VR1 DC1**, matching -the NetBox apex, which already holds both site records. This script previously -called them dc1/dc2 -- an off-by-one against the apex that would have bound -VR1 DC0's prefixes to the site vr1-dc1 (i.e. the OTHER DC), silently, because -the script also used to write by default. Both defects are fixed; see D-117. -EVERY "DCn" below is REGION-QUALIFIED, because "DC0" is ambiguous across VR0 -and VR1 and that ambiguity is exactly what produced the bug. +NAMING (D-117 + D-119): VR1's DCs are **VR1 DC0** (slug vr1-dc0) and **VR1 DC1** +(slug vr1-dc1), matching the NetBox apex, which already holds both site records. +This script once called them dc1/dc2 -- an off-by-one against the apex that would +have bound VR1 DC0's prefixes to the site vr1-dc1 (i.e. the OTHER DC), silently, +because it also used to write by default. Both defects are fixed. + +D-119 goes further: **the --dc selector IS the apex slug** (identity map). There is +no offset table left to get wrong. Bare dc0/dc1/dc2 are RETIRED -- 'dc0' used to +mean VR0's LIVE testcloud in scripts/lib-net.sh but VR1's FIRST DC here: one +string, two clouds. EVERY "DCn" below is REGION-QUALIFIED, because an unqualified +"DC0" is ambiguous across VR0 and VR1 and that ambiguity is what produced the bug. VR1 DC0 v4: INHERITS the **VR0 DC0** six-plane CIDRs unchanged (D-101 -- "the first DC equals the validated template"), i.e. the SAME literals as @@ -34,11 +38,12 @@ the rehearsal environment those literals came from. VR1 DC1 v4: D-101 explicitly states the second DC's supernet is "NetBox-assigned -... not hardcoded." No default is provided here -- DC1_V4_SUPERNET is a REQUIRED -environment variable with no fallback. Running with --dc dc1 and no -DC1_V4_SUPERNET set FAILS LOUD rather than guessing or reusing VR1 DC0's range. -(This env var was DC1_V4_SUPERNET before D-117; the old name is REJECTED with a -pointer rather than silently ignored.) +... not hardcoded." No default is provided here -- VR1_DC1_V4_SUPERNET is a REQUIRED +environment variable with no fallback. Running with --dc vr1-dc1 and no +VR1_DC1_V4_SUPERNET set FAILS LOUD rather than guessing or reusing VR1 DC0's range. +(It was DC2_V4_SUPERNET, then briefly DC1_V4_SUPERNET. BOTH old names are now +REJECTED BY NAME with a pointer: an unqualified "DC1" could mean VR0's dc0 OR VR1's +second DC, which is precisely the ambiguity D-119 deletes.) IPv6 (both DCs): D-101 names two literals as NetBox-assigned and "NOT hardcoded in this decision": the org ULA /48, and the per-DC GUA carve out @@ -85,17 +90,20 @@ # VR1 DC0 (v4 inherited from VR0 DC0; only v6 top-level blocks required): NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN= \\ ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f02::/48 \\ - python3 dc-dc-prefixes-import.py --dc dc0 # preview - ... python3 dc-dc-prefixes-import.py --dc dc0 --commit # write + python3 dc-dc-prefixes-import.py --dc vr1-dc0 # preview + ... python3 dc-dc-prefixes-import.py --dc vr1-dc0 --commit # write # VR1 DC1 (v4 supernet ALSO required, nothing defaulted): NETBOX_URL=... NETBOX_TOKEN=... \\ ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f03::/48 \\ - DC1_V4_SUPERNET=10.12.64.0/19 \\ - python3 dc-dc-prefixes-import.py --dc dc1 --commit + VR1_DC1_V4_SUPERNET=10.12.64.0/19 \\ + python3 dc-dc-prefixes-import.py --dc vr1-dc1 --commit + + # DC_GUA_PREFIX is CROSS-CHECKED against --dc (D-119): a mismatched pair is + # rejected, not silently written. # Verification block only (no plan, no writes): - ... python3 dc-dc-prefixes-import.py --dc dc0 --verify-only + ... python3 dc-dc-prefixes-import.py --dc vr1-dc0 --verify-only KNOWN PRE-EXISTING STALENESS (flagged, not fixed here): netbox/ipv4-prefixes- import.py's role slugs (provider/metal/lbaas-management/openstack-tenant) @@ -136,13 +144,35 @@ # D-010/D-103 say naming authority lives. That is how the off-by-one got in. # ----------------------------------------------------------------------------- -# D-117: these slugs are the APEX's, verified live against netbox.baldurkeep.com -# on 2026-07-13 (vr1-dc0 id=43 -> 2602:f3e2:f02::/48; vr1-dc1 id=44 -> f03::/48). +# D-119: THE SELECTOR *IS* THE APEX SLUG. This is an IDENTITY map, and that is the +# whole point -- there is no offset table left to get wrong. The original defect was +# a WRONG LOOKUP TABLE (--dc dc1 bound to slug vr1-dc1 while treating f02::/48 as +# dc1's, though the apex binds f02 to VR1 DC0), which would have written the FIRST +# DC's prefixes onto the SECOND DC's site, silently. Deleting the mapping deletes +# the bug class. +# +# Slugs verified live against netbox.baldurkeep.com (2026-07-14): +# 2602:f3e2:f02::/48 -> site vr1-dc0 "Virtual Region 1 (VR1) Datacenter 0 (DC0)" +# 2602:f3e2:f03::/48 -> site vr1-dc1 "Virtual Region 1 (VR1) Datacenter 1 (DC1)" # They are NOT a repo-local invention. Do not "fix" them to dc1/dc2 -- that is the -# exact off-by-one D-117 closed. Prior art for naming lives in the APEX, not here. +# exact off-by-one D-117 closed and D-119 made unrepresentable. SITES = { - "dc0": {"slug": "vr1-dc0", "name": "VR1 DC0"}, - "dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"}, + "vr1-dc0": {"slug": "vr1-dc0", "name": "VR1 DC0"}, + "vr1-dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"}, +} +# The structural invariant D-119 buys us. If a future edit reintroduces an offset, +# this fails at import time rather than silently mis-binding a datacenter. +assert all(k == v["slug"] for k, v in SITES.items()), \ + "SITES must be an IDENTITY map (D-119): the --dc selector IS the apex site slug" + +# The apex's MEASURED DC->GUA binding. The selector cannot be allowed to disagree +# with the addressing: without this, `--dc vr1-dc0 DC_GUA_PREFIX=` is accepted +# and writes the SECOND DC's GUA, with the FIRST DC's ULA nibble, onto the FIRST +# DC's site -- a silently mis-bound datacenter assembled from two disagreeing +# sources. Identity-mapping the slug alone does NOT close that; this does. +EXPECTED_GUA = { + "vr1-dc0": "2602:f3e2:f02::/48", + "vr1-dc1": "2602:f3e2:f03::/48", } # Fixed plane order -- must match scripts/lib-net.sh's PLANE_NAME exactly @@ -184,12 +214,12 @@ "replication": "10.12.36.0/22", } -# VR1 DC1 (the SECOND DC) v4: NO default. From DC1_V4_SUPERNET (env), carved into +# VR1 DC1 (the SECOND DC) v4: NO default. From VR1_DC1_V4_SUPERNET (env), carved into # six sequential /22s in PLANE_ORDER, exactly mirroring DC1's /22-per-plane # structure. The supernet must be large enough to hold six non-overlapping # /22s (a /19 holds exactly eight /22s -- six used, two spare; anything # smaller than /19 FAILS LOUD). -DC1_MIN_SUPERNET_PREFIXLEN = 19 # a /19 = exactly 8x /22, room for the six planes +VR1_DC1_MIN_SUPERNET_PREFIXLEN = 19 # a /19 = exactly 8x /22, room for the six planes def die(msg: str, code: int = 1) -> None: @@ -301,33 +331,33 @@ return net -def validate_dc1_supernet(cidr: str) -> ipaddress.IPv4Network: +def validate_vr1_dc1_supernet(cidr: str) -> ipaddress.IPv4Network: try: net = ipaddress.ip_network(cidr, strict=True) except ValueError as exc: - die(f"DC1_V4_SUPERNET='{cidr}' is not a valid CIDR: {exc}") + die(f"VR1_DC1_V4_SUPERNET='{cidr}' is not a valid CIDR: {exc}") if net.version != 4: - die(f"DC1_V4_SUPERNET='{cidr}' is not an IPv4 prefix") - if net.prefixlen > DC1_MIN_SUPERNET_PREFIXLEN: + die(f"VR1_DC1_V4_SUPERNET='{cidr}' is not an IPv4 prefix") + if net.prefixlen > VR1_DC1_MIN_SUPERNET_PREFIXLEN: die( - f"DC1_V4_SUPERNET='{cidr}' (/{net.prefixlen}) is too small to hold " - f"six /22 planes -- need at least a /{DC1_MIN_SUPERNET_PREFIXLEN}" + f"VR1_DC1_V4_SUPERNET='{cidr}' (/{net.prefixlen}) is too small to hold " + f"six /22 planes -- need at least a /{VR1_DC1_MIN_SUPERNET_PREFIXLEN}" ) tmpl_nets = [ipaddress.ip_network(c) for c in VR0_DC0_TEMPLATE_V4.values()] for d1 in tmpl_nets: if net.overlaps(d1): die( - f"DC1_V4_SUPERNET='{cidr}' overlaps VR1 DC0's inherited range {d1} -- " + f"VR1_DC1_V4_SUPERNET='{cidr}' overlaps VR1 DC0's inherited range {d1} -- " f"D-101 requires VR1 DC1 to be non-overlapping. Choose a different supernet." ) return net -def carve_dc1_v4(supernet: ipaddress.IPv4Network) -> dict: +def carve_vr1_dc1_v4(supernet: ipaddress.IPv4Network) -> dict: subnets = list(supernet.subnets(new_prefix=22)) if len(subnets) < len(PLANE_ORDER): die( - f"DC1_V4_SUPERNET yields only {len(subnets)} /22 subnet(s), " + f"VR1_DC1_V4_SUPERNET yields only {len(subnets)} /22 subnet(s), " f"need {len(PLANE_ORDER)}" ) return {plane: str(subnets[i]) for i, plane in enumerate(PLANE_ORDER)} @@ -341,7 +371,7 @@ # # Per-DC ULA /56 index aligned to the GUA site nibble (VR1 DC0 -> ...:02xx, VR1 DC1 -> # ...:03xx) so the 4th hextet reads DC.NN in BOTH families. -DC_V6_INDEX = {"dc0": 0x02, "dc1": 0x03} +DC_V6_INDEX = {"vr1-dc0": 0x02, "vr1-dc1": 0x03} def _sub_at(base: ipaddress.IPv6Network, new_prefixlen: int, index: int) -> ipaddress.IPv6Network: @@ -427,19 +457,22 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) - parser.add_argument("--dc", required=True, choices=["dc0", "dc1"], help="Which VR1 DC to import (D-117: dc0|dc1, matching the NetBox apex. The old dc1|dc2 labels were off by one -- see D-117)") + parser.add_argument("--dc", required=True, choices=["vr1-dc0", "vr1-dc1"], help="Which VR1 DC to import. D-119: the selector IS the apex site slug (identity map, no offset table). Bare dc0/dc1/dc2 are RETIRED -- they were ambiguous across regions.") parser.add_argument("--update", action="store_true", help="Update existing prefixes in place (requires --commit)") parser.add_argument("--commit", action="store_true", help="WRITE to NetBox. Default is a DRY RUN that writes nothing (D-117 -- this tool used to write by default)") parser.add_argument("--verify-only", action="store_true", help="Skip the plan entirely; only print the verification block") args = parser.parse_args() - # D-117: reject the pre-rename env var by NAME rather than ignoring it. An - # operator with muscle memory would otherwise get "required env missing" and - # have no idea the variable was renamed under them. - if os.environ.get("DC2_V4_SUPERNET") and not os.environ.get("DC1_V4_SUPERNET"): - die("DC2_V4_SUPERNET is set, but that name was RETIRED by D-117. VR1's " - "second DC is now 'dc1' (not 'dc2'), so the variable is DC1_V4_SUPERNET. " - "Re-export it under the new name -- refusing to guess.") + # D-119: reject BOTH historical names by NAME rather than ignoring them. An + # operator with muscle memory could plausibly export either -- and both are now + # ambiguous ("DC1" could mean VR0's dc0 OR VR1's second DC). Silence would give + # them "required env missing" with no hint the variable moved under them. + for retired in ("DC1_V4_SUPERNET", "DC2_V4_SUPERNET"): + if os.environ.get(retired) and not os.environ.get("VR1_DC1_V4_SUPERNET"): + die(f"{retired} is set, but that name is RETIRED (D-119). An unqualified " + "'DC1'/'DC2' is exactly the cross-region ambiguity D-119 deletes. The " + "variable is now VR1_DC1_V4_SUPERNET (VR1's SECOND DC, slug vr1-dc1). " + "Re-export it under the new name -- refusing to guess.") dc = args.dc commit = args.commit @@ -448,11 +481,22 @@ org_ula_48 = validate_ula_48(require_env("ORG_ULA_48", "D-101's org ULA /48 -- not defaulted, must be a real RFC 4193 /48 you generated")) dc_gua_prefix = validate_gua_prefix(require_env("DC_GUA_PREFIX", f"the {dc} carve out of ARIN 2602:f3e2::/32 region-0 /36 cited in D-101 -- not defaulted")) - if dc == "dc0": + # D-119 BREAK-2 GUARD. The selector must not be allowed to disagree with the + # addressing. Without this, `--dc vr1-dc0 DC_GUA_PREFIX=` is accepted: + # it writes the SECOND DC's GUA prefixes, with the FIRST DC's ULA nibble (the + # ULA index is keyed off --dc, independently), all scoped to the FIRST DC's + # site -- a silently mis-bound datacenter assembled from two disagreeing + # sources. Identity-mapping the site slug does NOT close this hole; this does. + if str(dc_gua_prefix) != EXPECTED_GUA[dc]: + die(f"DC_GUA_PREFIX={dc_gua_prefix} does not match {dc}'s MEASURED apex block " + f"({EXPECTED_GUA[dc]}). Refusing to bind one DC's addressing to another " + f"DC's site -- that is the exact off-by-one D-117/D-119 exist to kill.") + + if dc == "vr1-dc0": v4 = dict(VR0_DC0_TEMPLATE_V4) else: - second_supernet = validate_dc1_supernet(require_env("DC1_V4_SUPERNET", "D-101 explicitly requires the SECOND DC's v4 supernet be NetBox-assigned, not hardcoded (D-115 assigns 10.12.64.0/19)")) - v4 = carve_dc1_v4(second_supernet) + second_supernet = validate_vr1_dc1_supernet(require_env("VR1_DC1_V4_SUPERNET", "D-101 explicitly requires the SECOND DC's v4 supernet be NetBox-assigned, not hardcoded (D-115 assigns 10.12.64.0/19)")) + v4 = carve_vr1_dc1_v4(second_supernet) v6_recs = carve_v6(dc, org_ula_48, dc_gua_prefix) @@ -463,7 +507,7 @@ else: print(f"Connected. Site '{site_cfg['name']}' (id={site.id}){' -- CREATED' if site_created else ''}.") - if dc == "dc0": + if dc == "vr1-dc0": print( "\nNOTE: this may create prefixes identical to the existing 'vr0-dc0' " "site's entries (D-101: VR1 DC0 inherits VR0 DC0's v4 layout unchanged). " @@ -481,13 +525,13 @@ for plane in PLANE_ORDER: create_or_report_prefix( nb, v4[plane], plane, site, - f"VR1 {dc.upper()} {plane} (v4; D-101)", args.update, commit, + f"{site_cfg['name']} {plane} (v4; D-101)", args.update, commit, ) print("\nIPv6 prefixes (D-101 family matrix on the D-111 NN layout):") for cidr, role_slug, kind in v6_recs: create_or_report_prefix( nb, cidr, role_slug, site, - f"VR1 {dc.upper()} {role_slug} ({kind}; D-101/D-111)", + f"{site_cfg['name']} {role_slug} ({kind}; D-101/D-111)", args.update, commit, ) diff --git a/opentofu/main.tf b/opentofu/main.tf index 424416f..f75981d 100644 --- a/opentofu/main.tf +++ b/opentofu/main.tf @@ -21,38 +21,38 @@ # ---- DC1: inherits the DC0 six-plane v4 layout unchanged (D-101) ---- -module "dc1_planes" { +module "vr1_dc0_planes" { source = "./modules/dc-planes" - dc_name = "dc1" + dc_name = "vr1-dc0" domain_suffix = var.domain_suffix mtu = var.underlay_mtu - planes = var.dc1_planes + planes = var.vr1_dc0_planes } -module "dc1_storage" { +module "vr1_dc0_storage" { source = "./modules/dc-storage-pool" - dc_name = "dc1" - target_path = var.dc1_pool_path + dc_name = "vr1-dc0" + 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 dc2_planes. Do NOT +# 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 -# `dc2_planes` variable (same shape as `dc1_planes`) and uncomment this block. +# `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. # -# module "dc2_planes" { +# module "vr1_dc1_planes" { # source = "./modules/dc-planes" -# dc_name = "dc2" +# dc_name = "vr1-dc1" # domain_suffix = var.domain_suffix # mtu = var.underlay_mtu -# planes = var.dc2_planes +# planes = var.vr1_dc1_planes # } -module "dc2_storage" { +module "vr1_dc1_storage" { source = "./modules/dc-storage-pool" - dc_name = "dc2" - target_path = var.dc2_pool_path + dc_name = "vr1-dc1" + target_path = var.vr1_dc1_pool_path } # ---- Office1 headend: its own storage pool for MAAS-region/NetBox/GitBucket @@ -88,7 +88,7 @@ # owns creating it -- see that runbook's new Step 4b and its "Open questions" # section for the full reasoning). NOT instantiated below -- commented out on # purpose, mirroring the identical NOT-YET-REAL status of module -# "dc1_opnsense"/"dc2_opnsense" (see runbooks/dc-dc-phase2-tofu-dc-substrate.md +# "vr1_dc0_opnsense"/"vr1_dc1_opnsense" (see runbooks/dc-dc-phase2-tofu-dc-substrate.md # Step 5's own template, which is ALSO still all placeholders, nothing # committed here either). Every value below needs to be real before this is # uncommented -- do not fill in invented specs to wire it in sooner: @@ -120,21 +120,21 @@ # plane networks exist yet, and Phase 0's gate wants "virtual networks present # and isolated as designed" before Phase 2 needs them. ---- -module "mesh_dc1_dc2" { +module "mesh_vr1_dc0_vr1_dc1" { source = "./modules/mesh-link" - link_name = "dc1-dc2" + link_name = "vr1-dc0-vr1-dc1" mtu = var.underlay_mtu } -module "mesh_dc1_office1" { +module "mesh_vr1_dc0_office1" { source = "./modules/mesh-link" - link_name = "dc1-office1" + link_name = "vr1-dc0-office1" mtu = var.underlay_mtu } -module "mesh_dc2_office1" { +module "mesh_vr1_dc1_office1" { source = "./modules/mesh-link" - link_name = "dc2-office1" + link_name = "vr1-dc1-office1" mtu = var.underlay_mtu } @@ -220,3 +220,49 @@ dhcp4: true EOT } + +# ----------------------------------------------------------------------------- +# D-119 state migration. `moved` rewrites the STATE ADDRESS only -- it cannot +# suppress a ForceNew driven by an attribute, and `dc_name`/`link_name` are +# interpolated straight into each libvirt object's `name` (which IS ForceNew). +# So the libvirt networks/pools ARE replaced; these blocks exist so `tofu plan` +# reads as N clean "must be replaced" lines instead of N unrelated destroy+create +# pairs -- i.e. so the plan is REVIEWABLE. +# +# SAFE TO REPLACE, MEASURED 2026-07-14 BEFORE THE RENAME: all of these objects are +# EMPTY -- no guests are attached (Stage 3 has not run, so no DC node VMs exist) +# and no volumes live in either DC pool (the only volumes in state belong to +# office1_opnsense, ubuntu_noble_base and voffice1, none of which sit in a DC pool). +# Re-verify with `tofu plan` before applying; if a plan shows anything ATTACHED, +# STOP -- standing lesson 2: an apply touching a libvirt_domain's devices is an +# OUTAGE, and "updated in-place" does NOT mean "no restart". +# ----------------------------------------------------------------------------- +moved { + from = module.dc1_planes + to = module.vr1_dc0_planes +} + +moved { + from = module.dc1_storage + to = module.vr1_dc0_storage +} + +moved { + from = module.dc2_storage + to = module.vr1_dc1_storage +} + +moved { + from = module.mesh_dc1_dc2 + to = module.mesh_vr1_dc0_vr1_dc1 +} + +moved { + from = module.mesh_dc1_office1 + to = module.mesh_vr1_dc0_office1 +} + +moved { + from = module.mesh_dc2_office1 + to = module.mesh_vr1_dc1_office1 +} diff --git a/opentofu/modules/cloudinit-vm/variables.tf b/opentofu/modules/cloudinit-vm/variables.tf index 83d8ad5..9fcd1c1 100644 --- a/opentofu/modules/cloudinit-vm/variables.tf +++ b/opentofu/modules/cloudinit-vm/variables.tf @@ -73,7 +73,7 @@ TRUE -- pass the host's `svm` feature through. REQUIRED for any VM that is registered to MAAS as an LXD VM host and has VMs composed into it - (D-114: the site containment VMs voffice1/vdc1/vdc2). Without it the + (D-114: the site containment VMs voffice1/vvr1-dc0/vvr1-dc1). Without it the guest gets no svm flag, LXD virtual machines cannot start, and the D-114 model fails at its first step. FALSE -- disable `svm`, matching modules/opnsense-edge. Correct hardening for diff --git a/opentofu/variables.tf b/opentofu/variables.tf index 31f16c9..25e0aa3 100644 --- a/opentofu/variables.tf +++ b/opentofu/variables.tf @@ -21,7 +21,7 @@ type = number } -variable "dc1_pool_path" { +variable "vr1_dc0_pool_path" { description = "Host filesystem path for DC1's libvirt storage pool. Measured, real path on the vcloud host -- no default." type = string } @@ -31,12 +31,12 @@ type = string } -variable "dc2_pool_path" { +variable "vr1_dc1_pool_path" { description = "Host filesystem path for DC2's libvirt storage pool. Measured, real path on the vcloud host -- no default. NOTE: only DC2's STORAGE POOL is wired (operator ruling 2026-07-10, dc-dc-phase0 Step 5/7). DC2's PLANES stay deferred (Option B) pending NetBox assigning the D-101 supernet -- a storage pool has no address dependency, plane networks do." type = string } -variable "dc1_planes" { +variable "vr1_dc0_planes" { description = <<-EOT DC1's six planes. DC1 INHERITS the DC0 v4 layout UNCHANGED (D-101) -- these CIDRs are copied from scripts/lib-net.sh's PLANE_CIDRS array as it @@ -58,12 +58,17 @@ } } -# dc2_planes has NO variable/default here yet, deliberately: DC2's supernet is -# a D-101 OPEN SUB-ITEM ("exact prefixes assigned in NetBox, non-colliding with -# DC1..."), not yet assigned anywhere. Do not add a guessed default -- once -# NetBox assigns it, add a dc2_planes variable shaped exactly like dc1_planes -# above and a matching module "dc2_planes" block in main.tf (see the commented -# skeleton there). +# vr1_dc1_planes has NO variable/default here yet, deliberately: VR1 DC1's v4 +# supernet is a D-101 OPEN SUB-ITEM ("exact prefixes assigned in NetBox, +# non-colliding with..."), not yet wired here. Do not add a guessed default -- +# once NetBox assigns it, add a vr1_dc1_planes variable shaped exactly like +# vr1_dc0_planes above and a matching module "vr1_dc1_planes" block in main.tf +# (see the commented skeleton there). +# +# D-119 NAMING: vr1-dc0 is VR1's FIRST DC, vr1-dc1 its SECOND -- matching the +# NetBox apex slugs exactly. Never write a bare "dcN" for a VR1 DC here: "dc0" +# means VR0's LIVE testcloud in scripts/lib-net.sh, and that collision is what +# D-119 exists to delete. # ---- D-114: Office1 site containment VM (voffice1) ---------------------------- variable "office1_ssh_pubkey_path" { diff --git a/runbooks/dc-dc-phase0-vcloud-prep.md b/runbooks/dc-dc-phase0-vcloud-prep.md index 95f07d7..785aab2 100644 --- a/runbooks/dc-dc-phase0-vcloud-prep.md +++ b/runbooks/dc-dc-phase0-vcloud-prep.md @@ -216,8 +216,8 @@ instead of hand arithmetic:** ```bash bash scripts/dc-dc-ceph-disk-budget.sh --total-disk \ - --dc1-nodes --dc1-per-node-osd \ - --dc2-nodes --dc2-per-node-osd \ + --vr1-dc0-nodes --dc1-per-node-osd \ + --vr1-dc1-nodes --dc2-per-node-osd \ --backup-overhead-fraction ``` Target is size=3/min_size=2 BY DEFAULT (ADOPTED, not still open). The exact @@ -489,7 +489,7 @@ Expect: the six DC1 plane networks, the three mesh-link networks, and the `dc1`/`office1` storage pools, all `active`. Cross-check each plane network's CIDR against `opentofu/variables.tf`'s `dc1_planes` default (which mirrors -`scripts/lib-net.sh`'s `PLANE_CIDRS` -- DOCFIX-151's `lib_net_select_dc dc1` +`scripts/lib-net.sh`'s `PLANE_CIDRS` -- DOCFIX-151's `lib_net_select_dc vr1-dc0` no-op is the same six values, confirming both sources still agree). ```bash diff --git a/runbooks/dc-dc-phase1-office1-standup.md b/runbooks/dc-dc-phase1-office1-standup.md index 9f1b0ad..9afe578 100644 --- a/runbooks/dc-dc-phase1-office1-standup.md +++ b/runbooks/dc-dc-phase1-office1-standup.md @@ -162,7 +162,7 @@ text ("the org ULA /48, the per-DC GUA carve... and DC2's v4 supernet are NetBox-authoritative... NOT hardcoded in this decision"). This runbook stands NetBox up and can run its import MECHANISM (Step 10), but the underlying - `ORG_ULA_48` / `DC_GUA_PREFIX` / `DC2_V4_SUPERNET` values do not exist yet -- + `ORG_ULA_48` / `DC_GUA_PREFIX` / `VR1_DC1_V4_SUPERNET` values do not exist yet -- see Step 10's own gate. 4. **GitBucket: CLOSED, NOT DEFERRED (D-116).** There is NO Office1-local GitBucket. The operator ruled 2026-07-13 that the existing `git.baldurkeep.com` remains the git service of record, with @@ -649,7 +649,7 @@ tooling gap #3**: the real org ULA /48, the per-DC GUA carve, and DC2's v4 supernet do not exist yet as assigned values anywhere in this repo or session. Running it for DC1 requires the two v6 literals (`ORG_ULA_48`, `DC_GUA_PREFIX`); -running it for DC2 additionally requires `DC2_V4_SUPERNET` -- none of the three +running it for DC2 additionally requires `VR1_DC1_V4_SUPERNET` -- none of the three has a default, by the script's own design, and none is invented here. **CHECK -- dry run, no writes** @@ -658,7 +658,7 @@ NETBOX_TOKEN="" \ ORG_ULA_48="" \ DC_GUA_PREFIX="" \ -python3 netbox/dc-dc-prefixes-import.py --dc dc1 --verify-only +python3 netbox/dc-dc-prefixes-import.py --dc vr1-dc0 --verify-only ``` If `ORG_ULA_48` / `DC_GUA_PREFIX` are not yet assigned, this command CANNOT be run for real yet -- that is the honest state of this stage, not a missing step in @@ -671,18 +671,18 @@ **MUTATION -- once the literals above are real (a later session/step)** ```bash NETBOX_URL="..." NETBOX_TOKEN="..." ORG_ULA_48="..." DC_GUA_PREFIX="..." \ - python3 netbox/dc-dc-prefixes-import.py --dc dc1 + python3 netbox/dc-dc-prefixes-import.py --dc vr1-dc0 ``` DC1's v4 is hardcoded in the script itself (D-101: "DC1 equals the validated template," the explicit text of an ADOPTED decision, not an inferred value). DC2 -additionally needs `DC2_V4_SUPERNET`, not assigned yet either. +additionally needs `VR1_DC1_V4_SUPERNET`, not assigned yet either. **GATE (Stage 2 Gate, HONEST partial):** NetBox is reachable/running (Step 9). "Authoritative and populated (planes, per-DC v4, ULA/GUA carve)" is only PARTIALLY achievable: the IMPORT MECHANISM exists and is runnable the moment the literals are assigned, but the literals themselves are not assigned, so the DATA half of this gate bullet is NOT met. Do not mark this bullet green in the tracker -until a real `ORG_ULA_48` / `DC_GUA_PREFIX` (and, for DC2, `DC2_V4_SUPERNET`) +until a real `ORG_ULA_48` / `DC_GUA_PREFIX` (and, for DC2, `VR1_DC1_V4_SUPERNET`) exist and this script has actually run against this real NetBox instance. --- @@ -806,7 +806,7 @@ -> **Stage 3 (DC1) is GATED behind this stage.** D-114's sequencing is an operator ruling: Office1 FIRST, fully up on this model; DC1 is not started until Office1 is complete; DC2 follows DC1. DC1's own entry gate additionally requires -proving nested KVM inside `vdc1` (which also proves L4 for tenant instances -- +proving nested KVM inside `vvr1-dc0` (which also proves L4 for tenant instances -- the one depth VR0 has never exercised). **If L3 fails at Step 8, the containment-VM model is abandoned early having cost one VM** -- that is the designed cheap retreat, and it is the correct outcome to report, not a failure to diff --git a/runbooks/dc-dc-phase3-maas-enlist-deploy.md b/runbooks/dc-dc-phase3-maas-enlist-deploy.md index 854aeae..d6afa9a 100644 --- a/runbooks/dc-dc-phase3-maas-enlist-deploy.md +++ b/runbooks/dc-dc-phase3-maas-enlist-deploy.md @@ -3,7 +3,7 @@ Bring the per-DC node VMs that Stage 3 (`docs/dc-dc-deployment-workflow.md` -- "Stage 3 -- Phase 2: OpenTofu builds each DC substrate") caused MAAS to DISCOVER through to a deployed OS, ready for Juju (Stage 5 / Phase 4). Run -this runbook ONCE PER DC: set `DC=dc1` or `DC=dc2` for the whole session and +this runbook ONCE PER DC: set `DC=vr1-dc0` or `DC=vr1-dc1` for the whole session and do not mix the two DCs' commands in one shell. This is the FIRST DC-DC runbook whose subject is MAAS itself in the multi-rack VR1 shape -- everything before it (Stage 0-3) either ratified decisions or built the substrate MAAS @@ -74,15 +74,15 @@ `scripts/phase-00-maas-standup.sh` now accept an opt-in `$DC` env var and call `lib_net_select_dc`/`lib_hosts_select_dc` immediately after sourcing (unset `$DC` is byte-for-byte unchanged VR0/DC0 behavior). See - `docs/changelog-20260709-maas-scripts-dc-param.md`. This means `DC=dc1 + `docs/changelog-20260709-maas-scripts-dc-param.md`. This means `DC=vr1-dc0 bash scripts/reenroll-hosts.sh` (etc.) is now a REAL invocation, not hypothetical -- but it does not by itself unblock anything: calling any - of the three with `DC=dc1` or `DC=dc2` today fails loud immediately at + of the three with `DC=vr1-dc0` or `DC=vr1-dc1` today fails loud immediately at `lib_hosts_select_dc` (reenroll-hosts.sh, carve-host-interfaces.sh -- no per-DC host inventory exists yet) or, for dc2, at `lib_net_select_dc` first (gap #4 below). The one case genuinely simplified is `phase-00-maas-standup.sh` against `dc1`: since D-101 makes dc1 inherit - dc0's plane literals unchanged, `DC=dc1 bash + dc0's plane literals unchanged, `DC=vr1-dc0 bash scripts/phase-00-maas-standup.sh` is now a real, correct, gated call -- see the updated Step 8. Parameterizing these scripts to accept REAL per-DC data (once it exists) required no further script change; the @@ -98,11 +98,11 @@ (`openstack0-3`) carry over to `$DC` -- discover however many nodes Stage 3 actually created, live, in Step 2 below. 4. **DC2 is additionally blocked at the network-selector layer.** - `lib_net_select_dc dc2` FAILS LOUD today (D-101's NetBox-literals open + `lib_net_select_dc vr1-dc1` FAILS LOUD today (D-101's NetBox-literals open item, tooling gap register #3 -- no CIDRs assigned yet for DC2). This - runbook cannot proceed past Step 1 for `DC=dc2` until that closes. `dc1` + runbook cannot proceed past Step 1 for `DC=vr1-dc1` until that closes. `dc1` is unblocked at this layer (D-101: DC1 inherits DC0's v4 layout - unchanged, so `lib_net_select_dc dc1` is a documented no-op). + unchanged, so `lib_net_select_dc vr1-dc0` is a documented no-op). 5. **The per-DC artifact mirror (D-107) has no identified owning stage/module in this repo as surveyed for this runbook.** Stage 2's build list (Office1 headend) mentions GitBucket (the REPO mirror) and NetBox -- @@ -327,7 +327,7 @@ The VLAN-103 metal-internal stack is a REAL, already-established repo constant, safe to cite verbatim (`scripts/lib-net.sh`): `METAL_INTERNAL_VID="103"`, `METAL_INTERNAL_IFACE="br-internal"`, `METAL_INTERNAL_CIDR="10.12.12.0/22"` (the -last one is DC0/DC1's value only under `lib_net_select_dc dc1`'s no-op -- +last one is DC0/DC1's value only under `lib_net_select_dc vr1-dc0`'s no-op -- confirm the same call resolves the right CIDR for whichever `$DC` you are running against; it fails loud for `dc2` per gap #4). @@ -440,7 +440,7 @@ hardcoded `PLANES` table is ALSO dc1's real target, so ```bash -DC=dc1 bash scripts/phase-00-maas-standup.sh +DC=vr1-dc0 bash scripts/phase-00-maas-standup.sh ``` is now a real, correct, gated call for `dc1` -- run it directly instead of the manual space-read-and-compare below. For `dc2`, run it the same way and diff --git a/runbooks/dc-dc-phase4-juju-bundle-per-dc.md b/runbooks/dc-dc-phase4-juju-bundle-per-dc.md index 5bd2aa2..81428a2 100644 --- a/runbooks/dc-dc-phase4-juju-bundle-per-dc.md +++ b/runbooks/dc-dc-phase4-juju-bundle-per-dc.md @@ -95,7 +95,7 @@ **CHECK -- jumphost / wherever this session runs juju against this DC** ```bash -export DC=dc1 # or dc2 -- explicit, per this repo's $DC convention (never omitted) +export DC=vr1-dc0 # or dc2 -- explicit, per this repo's $DC convention (never omitted) source scripts/lib-net.sh; lib_net_select_dc "$DC" source scripts/lib-hosts.sh; lib_hosts_select_dc "$DC" echo "PLANE_CIDRS: ${PLANE_CIDRS[*]}" @@ -103,7 +103,7 @@ ``` **GATE:** both selector calls exit 0 (for DC2, this REQUIRES Stage 4 having already populated real host data into `scripts/lib-hosts.sh` for dc2 -- if -`lib_hosts_select_dc dc2` still fails loud here, Stage 4 for DC2 is not +`lib_hosts_select_dc vr1-dc1` still fails loud here, Stage 4 for DC2 is not actually done yet, stop and go back). --- @@ -161,7 +161,7 @@ note either way), not silently default to reuse. - **DC2:** BLOCKED until its VIP band exists (see above). Once NetBox assigns DC2's real supernet and it's imported via `netbox/dc-dc-prefixes- - import.py --dc dc2` (DOCFIX-152), `bundle.yaml`'s VIP lines need editing + import.py --dc vr1-dc1` (DOCFIX-152), `bundle.yaml`'s VIP lines need editing to DC2's real `.50-.60`-equivalent band within DC2's own provider-public plane -- a genuine, reviewed bundle edit, not a copy-paste of DC1's values. - Both DCs: `bundle.yaml` explicitly ships "NO designate" per phase-01's own diff --git a/runbooks/dc-dc-phase5-dr-failover-drill.md b/runbooks/dc-dc-phase5-dr-failover-drill.md index 7d60f76..b85ce2f 100644 --- a/runbooks/dc-dc-phase5-dr-failover-drill.md +++ b/runbooks/dc-dc-phase5-dr-failover-drill.md @@ -113,7 +113,7 @@ **CHECK -- both DCs independently, from wherever this session runs** ```bash source scripts/lib-net.sh -lib_net_select_dc "dc1" # explicit, once -- gap #1 / DOCFIX-151 call-site wiring +lib_net_select_dc "vr1-dc0" # explicit, once -- gap #1 / DOCFIX-151 call-site wiring # repeat the pair below for dc2 in its own shell/session context; do not assume dc2's # values from dc1 -- lib_net_select_dc fails loud for dc2 until its literals are real bash scripts/cloud-assert.sh diff --git a/runbooks/dc-dc-phase6-designate-cos-magnum.md b/runbooks/dc-dc-phase6-designate-cos-magnum.md index 53541fa..b63e6cb 100644 --- a/runbooks/dc-dc-phase6-designate-cos-magnum.md +++ b/runbooks/dc-dc-phase6-designate-cos-magnum.md @@ -121,7 +121,7 @@ ```bash # Set once per DC-pass through this runbook -- MEASURE from Stage 4's as-built # record, never guess a naming scheme: -# DC=dc1|dc2 +# DC=vr1-dc0|dc2 # DC_CONTROLLER= # DC_MODEL= --dc1-nodes -# --dc1-per-node-osd --dc2-nodes --dc2-per-node-osd +# scripts/dc-dc-ceph-disk-budget.sh --total-disk --vr1-dc0-nodes +# --dc1-per-node-osd --vr1-dc1-nodes --dc2-per-node-osd # --backup-overhead-fraction # # Phase-0 gate calculator (tooling gap register item #7): turns @@ -52,21 +52,21 @@ # # Usage: # dc-dc-ceph-disk-budget.sh --total-disk \ -# --dc1-nodes --dc1-per-node-osd \ -# --dc2-nodes --dc2-per-node-osd \ +# --vr1-dc0-nodes --dc1-per-node-osd \ +# --vr1-dc1-nodes --dc2-per-node-osd \ # --backup-overhead-fraction # # --total-disk SIZE REQUIRED. Measured total disk on the # vcloud host (Step 2's `df`/`lsblk` # output). No default. -# --dc1-nodes N REQUIRED. Measured/planned Ceph OSD node +# --vr1-dc0-nodes N REQUIRED. Measured/planned Ceph OSD node # count, DC1. No default. # --dc1-per-node-osd SIZE REQUIRED. Measured/planned per-node raw # OSD disk footprint, DC1 (already # reflecting DC1's own size=3 replication # as it would actually consume disk). No # default. -# --dc2-nodes N REQUIRED. Same, DC2. No default. +# --vr1-dc1-nodes N REQUIRED. Same, DC2. No default. # --dc2-per-node-osd SIZE REQUIRED. Same, DC2. No default. # --backup-overhead-fraction F REQUIRED. Decimal fraction (e.g. 0.4 for # 40%) of the combined DC1+DC2 Ceph total @@ -90,18 +90,18 @@ usage() { sed -n '2,85p' "$HERE/$(basename "${BASH_SOURCE[0]}")" | sed 's/^# \{0,1\}//'; } -TOTAL_DISK=""; DC1_NODES=""; DC1_PER_NODE=""; DC2_NODES=""; DC2_PER_NODE=""; OVERHEAD_FRAC="" +TOTAL_DISK=""; VR1_DC0_NODES=""; DC1_PER_NODE=""; VR1_DC1_NODES=""; DC2_PER_NODE=""; OVERHEAD_FRAC="" while [ $# -gt 0 ]; do case "$1" in --total-disk) TOTAL_DISK="${2:-}"; shift 2 ;; --total-disk=*) TOTAL_DISK="${1#*=}"; shift ;; - --dc1-nodes) DC1_NODES="${2:-}"; shift 2 ;; - --dc1-nodes=*) DC1_NODES="${1#*=}"; shift ;; + --vr1-dc0-nodes) VR1_DC0_NODES="${2:-}"; shift 2 ;; + --vr1-dc0-nodes=*) VR1_DC0_NODES="${1#*=}"; shift ;; --dc1-per-node-osd) DC1_PER_NODE="${2:-}"; shift 2 ;; --dc1-per-node-osd=*) DC1_PER_NODE="${1#*=}"; shift ;; - --dc2-nodes) DC2_NODES="${2:-}"; shift 2 ;; - --dc2-nodes=*) DC2_NODES="${1#*=}"; shift ;; + --vr1-dc1-nodes) VR1_DC1_NODES="${2:-}"; shift 2 ;; + --vr1-dc1-nodes=*) VR1_DC1_NODES="${1#*=}"; shift ;; --dc2-per-node-osd) DC2_PER_NODE="${2:-}"; shift 2 ;; --dc2-per-node-osd=*) DC2_PER_NODE="${1#*=}"; shift ;; --backup-overhead-fraction) OVERHEAD_FRAC="${2:-}"; shift 2 ;; @@ -113,8 +113,8 @@ # --- validation: every input REQUIRED, no defaults (hard rule 2) ------------ missing=0 -for pair in "--total-disk:$TOTAL_DISK" "--dc1-nodes:$DC1_NODES" \ - "--dc1-per-node-osd:$DC1_PER_NODE" "--dc2-nodes:$DC2_NODES" \ +for pair in "--total-disk:$TOTAL_DISK" "--vr1-dc0-nodes:$VR1_DC0_NODES" \ + "--dc1-per-node-osd:$DC1_PER_NODE" "--vr1-dc1-nodes:$VR1_DC1_NODES" \ "--dc2-per-node-osd:$DC2_PER_NODE" \ "--backup-overhead-fraction:$OVERHEAD_FRAC"; do name="${pair%%:*}"; val="${pair#*:}" @@ -126,8 +126,8 @@ [ "$missing" -eq 1 ] && exit 2 is_pos_int() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]; } -if ! is_pos_int "$DC1_NODES"; then echo "FAIL: --dc1-nodes must be a positive integer; got '$DC1_NODES'" >&2; exit 2; fi -if ! is_pos_int "$DC2_NODES"; then echo "FAIL: --dc2-nodes must be a positive integer; got '$DC2_NODES'" >&2; exit 2; fi +if ! is_pos_int "$VR1_DC0_NODES"; then echo "FAIL: --vr1-dc0-nodes must be a positive integer; got '$VR1_DC0_NODES'" >&2; exit 2; fi +if ! is_pos_int "$VR1_DC1_NODES"; then echo "FAIL: --vr1-dc1-nodes must be a positive integer; got '$VR1_DC1_NODES'" >&2; exit 2; fi # fraction: decimal >= 0 (e.g. 0, 0.4, 1, 1.25) if ! [[ "$OVERHEAD_FRAC" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then @@ -166,8 +166,8 @@ printf '%d.%02d' "$((tib_scaled / 100))" "$((tib_scaled % 100))" } -DC1_TOTAL_B=$((DC1_NODES * DC1_PER_NODE_B)) -DC2_TOTAL_B=$((DC2_NODES * DC2_PER_NODE_B)) +DC1_TOTAL_B=$((VR1_DC0_NODES * DC1_PER_NODE_B)) +DC2_TOTAL_B=$((VR1_DC1_NODES * DC2_PER_NODE_B)) CEPH_TOTAL_B=$((DC1_TOTAL_B + DC2_TOTAL_B)) # overhead = ceph_total * fraction, computed in fixed-point (avoid bash's @@ -179,8 +179,8 @@ REQUIRED_B=$((CEPH_TOTAL_B + OVERHEAD_B)) echo "=== dc-dc-ceph-disk-budget: Section 3 / D-101 Ceph disk-budget gate ===" -echo " DC1: $DC1_NODES node(s) x $DC1_PER_NODE per-node OSD footprint = $(to_tib_str "$DC1_TOTAL_B") TiB" -echo " DC2: $DC2_NODES node(s) x $DC2_PER_NODE per-node OSD footprint = $(to_tib_str "$DC2_TOTAL_B") TiB" +echo " DC1: $VR1_DC0_NODES node(s) x $DC1_PER_NODE per-node OSD footprint = $(to_tib_str "$DC1_TOTAL_B") TiB" +echo " DC2: $VR1_DC1_NODES node(s) x $DC2_PER_NODE per-node OSD footprint = $(to_tib_str "$DC2_TOTAL_B") TiB" echo " Combined DC1+DC2 Ceph (size=3, as measured/planned): $(to_tib_str "$CEPH_TOTAL_B") TiB" echo " Backup/mirror/image overhead (--backup-overhead-fraction $OVERHEAD_FRAC of combined Ceph): $(to_tib_str "$OVERHEAD_B") TiB" echo " Required total (size=3 + overhead): $(to_tib_str "$REQUIRED_B") TiB" diff --git a/scripts/dc-dc-dr-drill.sh b/scripts/dc-dc-dr-drill.sh index e3dc4fd..3a8b111 100644 --- a/scripts/dc-dc-dr-drill.sh +++ b/scripts/dc-dc-dr-drill.sh @@ -54,11 +54,11 @@ # assigned network literals, so no real DC2 Ceph cluster exists to target). # # Usage: -# dc-dc-dr-drill.sh failover --dc --model M --unit U --pool NAME \ +# dc-dc-dr-drill.sh failover --dc --model M --unit U --pool NAME \ # --confirmed-down [--reregister-image ID ...] [--apply] # dc-dc-dr-drill.sh failback --pool NAME \ -# --recovering-dc --recovering-model M --recovering-unit U \ -# --primary-dc --primary-model M --primary-unit U \ +# --recovering-dc --recovering-model M --recovering-unit U \ +# --primary-dc --primary-model M --primary-unit U \ # [--skip-11-4] [--apply] [--no-prompt] # dc-dc-dr-drill.sh --help # @@ -71,7 +71,7 @@ usage() { cat <<'EOF' usage: - dc-dc-dr-drill.sh failover --dc dc1|dc2 --model M --unit U --pool NAME \ + dc-dc-dr-drill.sh failover --dc vr1-dc0|vr1-dc1 --model M --unit U --pool NAME \ --confirmed-down [--reregister-image ID ...] [--apply] (--dc/--model/--unit identify the DC being PROMOTED -- the surviving side. --confirmed-down is a required acknowledgement that runbook Step @@ -79,8 +79,8 @@ already been done; this script does not perform that check itself.) dc-dc-dr-drill.sh failback --pool NAME \ - --recovering-dc dc1|dc2 --recovering-model M --recovering-unit U \ - --primary-dc dc1|dc2 --primary-model M --primary-unit U \ + --recovering-dc vr1-dc0|vr1-dc1 --recovering-model M --recovering-unit U \ + --primary-dc vr1-dc0|vr1-dc1 --primary-model M --primary-unit U \ [--skip-11-4] [--apply] [--no-prompt] (recovering = the DC coming back up; primary = the DC currently serving as primary since the failover. Order is hard-coded: demote @@ -147,8 +147,8 @@ esac done - [ -n "$DC" ] || { echo "FAIL: --dc is required (dc1|dc2 -- the DC being promoted)" >&2; exit 1; } - case "$DC" in dc1|dc2) ;; *) echo "FAIL: --dc must be dc1 or dc2 (got '$DC')" >&2; exit 1 ;; esac + [ -n "$DC" ] || { echo "FAIL: --dc is required (vr1-dc0|vr1-dc1 -- the DC being promoted)" >&2; exit 1; } + case "$DC" in vr1-dc0|vr1-dc1) ;; *) echo "FAIL: --dc must be vr1-dc0 or vr1-dc1 (got '$DC'). D-119: bare dcN is RETIRED -- it was ambiguous across regions." >&2; exit 1 ;; esac [ -n "$UNIT" ] || { echo "FAIL: --unit is required" >&2; exit 1; } [ -n "$POOL" ] || { echo "FAIL: --pool is required (Glance pool name -- confirm, never assume 'glance')" >&2; exit 1; } @@ -221,10 +221,10 @@ done [ -n "$POOL" ] || { echo "FAIL: --pool is required" >&2; exit 1; } - [ -n "$REC_DC" ] || { echo "FAIL: --recovering-dc is required (dc1|dc2)" >&2; exit 1; } - [ -n "$PRI_DC" ] || { echo "FAIL: --primary-dc is required (dc1|dc2)" >&2; exit 1; } - case "$REC_DC" in dc1|dc2) ;; *) echo "FAIL: --recovering-dc must be dc1 or dc2 (got '$REC_DC')" >&2; exit 1 ;; esac - case "$PRI_DC" in dc1|dc2) ;; *) echo "FAIL: --primary-dc must be dc1 or dc2 (got '$PRI_DC')" >&2; exit 1 ;; esac + [ -n "$REC_DC" ] || { echo "FAIL: --recovering-dc is required (vr1-dc0|vr1-dc1)" >&2; exit 1; } + [ -n "$PRI_DC" ] || { echo "FAIL: --primary-dc is required (vr1-dc0|vr1-dc1)" >&2; exit 1; } + case "$REC_DC" in vr1-dc0|vr1-dc1) ;; *) echo "FAIL: --recovering-dc must be vr1-dc0 or vr1-dc1 (got '$REC_DC'). D-119: bare dcN is RETIRED." >&2; exit 1 ;; esac + case "$PRI_DC" in vr1-dc0|vr1-dc1) ;; *) echo "FAIL: --primary-dc must be vr1-dc0 or vr1-dc1 (got '$PRI_DC'). D-119: bare dcN is RETIRED." >&2; exit 1 ;; esac [ "$REC_DC" != "$PRI_DC" ] || { echo "FAIL: --recovering-dc and --primary-dc must differ (got '$REC_DC' twice)" >&2; exit 1; } [ -n "$REC_UNIT" ] || { echo "FAIL: --recovering-unit is required" >&2; exit 1; } [ -n "$PRI_UNIT" ] || { echo "FAIL: --primary-unit is required" >&2; exit 1; } diff --git a/scripts/dc-dc-radosgw-multisite.sh b/scripts/dc-dc-radosgw-multisite.sh index 815e36a..a66d18b 100644 --- a/scripts/dc-dc-radosgw-multisite.sh +++ b/scripts/dc-dc-radosgw-multisite.sh @@ -64,13 +64,13 @@ usage() { cat <<'EOF' usage: - dc-dc-radosgw-multisite.sh master-init --dc dc1|dc2 --model M --unit U \ + dc-dc-radosgw-multisite.sh master-init --dc vr1-dc0|vr1-dc1 --model M --unit U \ --realm NAME --zonegroup NAME --zone NAME --endpoint URL \ [--restart-action ACTION] [--apply] - dc-dc-radosgw-multisite.sh join-readonly --dc dc1|dc2 --model M --unit U \ + dc-dc-radosgw-multisite.sh join-readonly --dc vr1-dc0|vr1-dc1 --model M --unit U \ --zonegroup NAME --zone NAME --endpoint URL \ --access-key KEY --secret SECRET [--restart-action ACTION] [--apply] - dc-dc-radosgw-multisite.sh enable-two-way --dc dc1|dc2 --model M --unit U \ + dc-dc-radosgw-multisite.sh enable-two-way --dc vr1-dc0|vr1-dc1 --model M --unit U \ --zone NAME [--apply] dc-dc-radosgw-multisite.sh --help @@ -115,8 +115,8 @@ # subcommands (Step 4) -- enable-two-way (Step 8) only MODIFIES an existing # zone's read-only flag, so it does not need them; forcing them here would # make an operator supply irrelevant values just to satisfy the parser. -[ -n "$DC" ] || { echo "FAIL: --dc is required (dc1|dc2)" >&2; exit 1; } -case "$DC" in dc1|dc2) ;; *) echo "FAIL: --dc must be dc1 or dc2 (got '$DC')" >&2; exit 1 ;; esac +[ -n "$DC" ] || { echo "FAIL: --dc is required (vr1-dc0|vr1-dc1)" >&2; exit 1; } +case "$DC" in vr1-dc0|vr1-dc1) ;; *) echo "FAIL: --dc must be vr1-dc0 or vr1-dc1 (got '$DC'). D-119: bare dcN is RETIRED -- it was ambiguous across regions." >&2; exit 1 ;; esac [ -n "$UNIT" ] || { echo "FAIL: --unit is required (e.g. ceph-radosgw/0 -- measured this session, never assumed)" >&2; exit 1; } [ -n "$ZONE" ] || { echo "FAIL: --zone is required" >&2; exit 1; } diff --git a/scripts/dc-dc-rbd-mirror.sh b/scripts/dc-dc-rbd-mirror.sh index 2876abf..13433dd 100644 --- a/scripts/dc-dc-rbd-mirror.sh +++ b/scripts/dc-dc-rbd-mirror.sh @@ -61,9 +61,9 @@ usage() { cat <<'EOF' usage: - dc-dc-rbd-mirror.sh bootstrap-primary --dc dc1|dc2 --model M --unit U \ + dc-dc-rbd-mirror.sh bootstrap-primary --dc vr1-dc0|vr1-dc1 --model M --unit U \ --pool NAME --site-name NAME [--token-out /remote/path] [--apply] - dc-dc-rbd-mirror.sh bootstrap-secondary --dc dc1|dc2 --model M --unit U \ + dc-dc-rbd-mirror.sh bootstrap-secondary --dc vr1-dc0|vr1-dc1 --model M --unit U \ --pool NAME --site-name NAME --direction rx-only|rx-tx \ --token-in /remote/path [--apply] dc-dc-rbd-mirror.sh --help @@ -105,8 +105,8 @@ done # --- guard clauses: required args, no invented defaults --- -[ -n "$DC" ] || { echo "FAIL: --dc is required (dc1|dc2)" >&2; exit 1; } -case "$DC" in dc1|dc2) ;; *) echo "FAIL: --dc must be dc1 or dc2 (got '$DC')" >&2; exit 1 ;; esac +[ -n "$DC" ] || { echo "FAIL: --dc is required (vr1-dc0|vr1-dc1)" >&2; exit 1; } +case "$DC" in vr1-dc0|vr1-dc1) ;; *) echo "FAIL: --dc must be vr1-dc0 or vr1-dc1 (got '$DC'). D-119: bare dcN is RETIRED -- it was ambiguous across regions." >&2; exit 1 ;; esac [ -n "$UNIT" ] || { echo "FAIL: --unit is required (e.g. ceph-rbd-mirror/0 or ceph-mon/0 -- measured, never assumed)" >&2; exit 1; } [ -n "$POOL" ] || { echo "FAIL: --pool is required (confirm against 'ceph osd pool ls' + 'juju config glance rbd-pool' -- do not assume 'glance', per the runbook)" >&2; exit 1; } [ -n "$SITE_NAME" ] || { echo "FAIL: --site-name is required" >&2; exit 1; } diff --git a/scripts/lib-hosts.sh b/scripts/lib-hosts.sh index 07ac646..46a25b6 100644 --- a/scripts/lib-hosts.sh +++ b/scripts/lib-hosts.sh @@ -59,28 +59,37 @@ # --- $DC selector convention (2026-07-09, tooling gap register #1) --- # -# Mirrors lib-net.sh's lib_net_select_dc -- but the answer here is honestly -# different, not a parallel no-op: HOSTS / HOST_OCTET / HOST_BOOT_MAC above -# are VR0/DC0's real, measured, enrolled hosts. DC1's and DC2's node VMs are -# created by opentofu/modules/node-vm (Stage 3 / buildout-design Phase 2), -# which has not run -- their hostnames, boot MACs, and octets do not exist -# yet, and VIRSH_POWER_ADDRESS above is DC0's own vcloud host connection -# (the buildout design's VR1 vcloud host is a distinct, not-yet-measured -# endpoint -- see opentofu's root `libvirt_uri` variable, also unset). -# Selecting dc1 or dc2 FAILS LOUDLY rather than inventing placeholder host -# data or silently reusing DC0's real hosts for a different DC. +# Mirrors lib-net.sh's lib_net_select_dc -- SAME region-qualified selectors +# (D-119), but the answer here is honestly different, not a parallel no-op: +# HOSTS / HOST_OCTET / HOST_BOOT_MAC above are VR0 DC0's real, measured, +# enrolled hosts. VR1 DC0's and VR1 DC1's node VMs are created by +# opentofu/modules/node-vm (Stage 3 / buildout-design Phase 2), which has not +# run -- their hostnames, boot MACs, and octets do not exist yet, and +# VIRSH_POWER_ADDRESS above is VR0 DC0's own vcloud host connection (the +# buildout design's VR1 vcloud host is a distinct, not-yet-measured endpoint -- +# see opentofu's root `libvirt_uri` variable, also unset). Selecting either VR1 +# DC FAILS LOUDLY rather than inventing placeholder host data or silently +# reusing VR0 DC0's real hosts for a different DC. +# +# DELIBERATE ASYMMETRY WITH lib-net.sh: `vr1-dc0` is a NO-OP there (D-101 says +# it inherits VR0 DC0's v4 layout) but FAILS here (it has no enrolled hosts). +# That is intentional and is asserted by tests/dc-selector. lib_hosts_select_dc() { - local dc="${1:?usage: lib_hosts_select_dc }" + local dc="${1:?usage: lib_hosts_select_dc }" case "$dc" in - dc0) + vr0-dc0) : # already the sourced defaults above -- explicit no-op, not a guess. ;; - dc1|dc2) + vr1-dc0|vr1-dc1) echo "FAIL: $dc has no enrolled hosts yet -- Phase 2/3 node-VM creation (opentofu/modules/node-vm) and MAAS enrollment haven't run for it. Do not invent HOSTS/HOST_OCTET/HOST_BOOT_MAC/VIRSH_POWER_ADDRESS values; populate this file with a real \$dc block once those hosts are actually enrolled and measured." >&2 return 1 ;; + dc0|dc1|dc2) + echo "FAIL: bare '$dc' is RETIRED (D-119). It was AMBIGUOUS ACROSS REGIONS: 'dc0' meant VR0's live DC0 here, but VR1's FIRST DC in the NetBox importer. Use the region-qualified selector: vr0-dc0 | vr1-dc0 | vr1-dc1" >&2 + return 1 + ;; *) - echo "FAIL: unknown DC '$dc' (expected dc0, dc1, or dc2)" >&2 + echo "FAIL: unknown DC '$dc' (expected vr0-dc0, vr1-dc0, or vr1-dc1)" >&2 return 1 ;; esac diff --git a/scripts/lib-net.sh b/scripts/lib-net.sh index 5db72bd..ec326a6 100644 --- a/scripts/lib-net.sh +++ b/scripts/lib-net.sh @@ -83,26 +83,40 @@ # the $REPO convention (DOCFIX-139/141): one explicit, measured selector, # never an inferred default. # -# dc0 and dc1 are the SAME values: D-101 (ADOPTED 2026-07-09) rules that -# DC1 inherits DC0's v4 layout UNCHANGED, so there is deliberately no -# second copy of these literals to drift out of sync with the ones above -- -# selecting either is a no-op against what sourcing this file already set. -# dc2 has NO assigned values yet (D-101's NetBox-literals open item, gap #3 -# in the tooling gap register) -- selecting it FAILS LOUDLY. It does not -# silently fall back to dc0/dc1's values or invent one; that silent-reuse -# failure mode is exactly what this convention exists to prevent. +# SELECTORS ARE REGION-QUALIFIED (D-119, ADOPTED 2026-07-14). The selector +# string IS the NetBox apex site slug -- vr0-dc0 / vr1-dc0 / vr1-dc1 -- so the +# shell, the OpenTofu modules and the IPAM authority all speak ONE namespace. +# +# WHY, because a bare `dcN` looks harmless: before D-119, `dc0` meant VR0's +# LIVE testcloud HERE, but VR1's FIRST DC in the NetBox importer. One string, +# two clouds, one of them in production. A bare dcN is therefore REJECTED -- +# accepting it "for compatibility" would preserve exactly the ambiguity D-119 +# exists to delete. lib_net_select_dc() { - local dc="${1:?usage: lib_net_select_dc }" + local dc="${1:?usage: lib_net_select_dc }" case "$dc" in - dc0|dc1) - : # already the sourced defaults above -- explicit no-op, not a guess. + vr0-dc0) + : # VR0's DC0 -- the LIVE testcloud. The literals sourced above ARE its + # real, measured values. Explicit no-op, not a guess. ;; - dc2) - echo "FAIL: dc2 has no assigned network literals yet (D-101 NetBox-literals open item, tooling gap register #3) -- do not select dc2 until NetBox assigns real CIDRs for it" >&2 + vr1-dc0) + : # VR1's FIRST DC. Same values as vr0-dc0, but for a DIFFERENT REASON: + # D-101 rules that VR1's first DC INHERITS VR0 DC0's v4 layout UNCHANGED. + # This is a SEPARATE case arm ON PURPOSE. The day D-101 stops holding, + # this arm diverges and vr0-dc0's must not. Folding the two arms together + # would encode the coincidence and lose the reason -- and the reason is + # the thing that changes (VR1 DC0 gains its own v6 per the family matrix). + ;; + vr1-dc1) + echo "FAIL: vr1-dc1 (VR1's SECOND DC) has no assigned network literals yet (D-101 NetBox-literals open item, tooling gap register #3) -- do not select it until NetBox assigns real CIDRs for it" >&2 + return 1 + ;; + dc0|dc1|dc2) + echo "FAIL: bare '$dc' is RETIRED (D-119). It was AMBIGUOUS ACROSS REGIONS: 'dc0' meant VR0's live DC0 here, but VR1's FIRST DC in the NetBox importer. Use the region-qualified selector: vr0-dc0 | vr1-dc0 | vr1-dc1" >&2 return 1 ;; *) - echo "FAIL: unknown DC '$dc' (expected dc0, dc1, or dc2)" >&2 + echo "FAIL: unknown DC '$dc' (expected vr0-dc0, vr1-dc0, or vr1-dc1)" >&2 return 1 ;; esac diff --git a/scripts/phase-00-maas-standup.sh b/scripts/phase-00-maas-standup.sh index 46d8c65..8044462 100644 --- a/scripts/phase-00-maas-standup.sh +++ b/scripts/phase-00-maas-standup.sh @@ -34,7 +34,7 @@ # CLI forms verified against Canonical MAAS how-to-manage-networks. # ASCII + LF. # -# DC selector (opt-in, DOCFIX-166): set DC=dc0|dc1|dc2 to call +# DC selector (opt-in, DOCFIX-166): set DC=vr0-dc0|vr1-dc0|vr1-dc1 to call # lib_net_select_dc explicitly. Unset (default) == today's D-052/D-053 # behavior, unchanged. dc1 is a genuine no-op (D-101); dc2 FAILS LOUD (gap # #3) -- but note the PLANES table itself is still DC0-hardcoded, so this @@ -52,7 +52,7 @@ # runbook's Step 1: an explicit $DC env var, never an inferred default. # Unset/empty $DC changes NOTHING -- this script runs exactly as it always # has, implicitly against the D-052/D-053 plane scheme (backward compatible -# by construction). Set DC=dc0|dc1|dc2 to select explicitly; dc2 FAILS LOUD +# by construction). Set DC=vr0-dc0|vr1-dc0|vr1-dc1 to select explicitly (D-119); vr1-dc1 FAILS LOUD # today (no NetBox-assigned literals yet -- gap #3) via set -e, no second # validation layer added. This script does not source lib-hosts.sh (it never # touches host identity), so only the network selector is called here. diff --git a/scripts/reenroll-hosts.sh b/scripts/reenroll-hosts.sh index daa8d92..8a835dc 100644 --- a/scripts/reenroll-hosts.sh +++ b/scripts/reenroll-hosts.sh @@ -11,7 +11,7 @@ # (default) create any of the four that are MISSING, then poll all four to Ready # --check read-only: report current status of openstack0-3 (no mutation) # -# DC selector (opt-in, DOCFIX-166): set DC=dc0|dc1|dc2 to call +# DC selector (opt-in, DOCFIX-166): set DC=vr0-dc0|vr1-dc0|vr1-dc1 to call # lib_net_select_dc/lib_hosts_select_dc explicitly. Unset (default) == DC0's # real, enrolled hosts, unchanged. dc1/dc2 currently FAIL LOUD (no per-DC host # inventory exists yet) -- see scripts/lib-hosts.sh. @@ -42,7 +42,7 @@ # dc-dc-phase3 runbook's Step 1: an explicit $DC env var, never an inferred # default. Unset/empty $DC changes NOTHING -- this script runs exactly as it # always has, implicitly against DC0/VR0's real, enrolled hosts (backward -# compatible by construction). Set DC=dc0|dc1|dc2 to select explicitly; the +# compatible by construction). Set DC=vr0-dc0|vr1-dc0|vr1-dc1 to select explicitly (D-119); the # selectors' own fail-loud behavior becomes this script's own exit code via # set -e (lib_hosts_select_dc fails for BOTH dc1 and dc2 today -- no per-DC # host inventory exists yet for either). No second validation layer is added. diff --git a/scripts/site-headend-install.sh b/scripts/site-headend-install.sh index ef94b02..46e7dd7 100644 --- a/scripts/site-headend-install.sh +++ b/scripts/site-headend-install.sh @@ -6,7 +6,7 @@ # service machines into it. This is D-114, and it is VR0's proven `lxd` + `tailscale` # pattern applied per site. # -# RUN IT ON THE SITE HOST (voffice1 today; vdc1/vdc2 later), as root: +# RUN IT ON THE SITE HOST (voffice1 today; vvr1-dc0/vvr1-dc1 later), as root: # ssh 'sudo bash -s' -- --compose-cidr 10.10.1.0/24 < scripts/site-headend-install.sh # # --check report what is installed/configured; mutate nothing; exit 0 ok / 1 incomplete diff --git a/tests/carve-host-interfaces/run-tests.sh b/tests/carve-host-interfaces/run-tests.sh index 2fc25f5..9574a53 100644 --- a/tests/carve-host-interfaces/run-tests.sh +++ b/tests/carve-host-interfaces/run-tests.sh @@ -79,15 +79,15 @@ echo echo "=== \$DC selector wiring (DOCFIX-166; tooling gap register #15 sub-item 2) ===" -run 0 "DC=dc0 explicit: identical no-op full carve plan" "$FIX/sub_ok.json" "$FIX/if_fresh.json" dc0 +run 0 "DC=vr0-dc0 explicit: identical no-op full carve plan" "$FIX/sub_ok.json" "$FIX/if_fresh.json" vr0-dc0 has 'WOULD: create br-ex \(OVS\) parent=enp1s0\(id=100\)' has 'Summary: 0 fatal' -run_dc 1 "DC=dc1: hosts selector fails loud (no per-DC host data)" dc1 +run_dc 1 "DC=vr1-dc0: hosts selector fails loud (no per-DC host data)" vr1-dc0 has 'no enrolled hosts yet' absent 'DO:|WOULD:' # must exit before any MAAS interaction -run_dc 1 "DC=dc2: net selector fails loud first (NetBox gap)" dc2 +run_dc 1 "DC=vr1-dc1: net selector fails loud first (NetBox gap)" vr1-dc1 has 'no assigned network literals yet' run_dc 1 "DC=bogus: unknown token fails loud" bogus diff --git a/tests/dc-dc-ceph-disk-budget/run-tests.sh b/tests/dc-dc-ceph-disk-budget/run-tests.sh index d43afcc..28284ab 100644 --- a/tests/dc-dc-ceph-disk-budget/run-tests.sh +++ b/tests/dc-dc-ceph-disk-budget/run-tests.sh @@ -19,10 +19,10 @@ fi } -FITS_ARGS=(--total-disk 10Ti --dc1-nodes 3 --dc1-per-node-osd 500G \ - --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3) -SHORT_ARGS=(--total-disk 2Ti --dc1-nodes 3 --dc1-per-node-osd 500G \ - --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3) +FITS_ARGS=(--total-disk 10Ti --vr1-dc0-nodes 3 --dc1-per-node-osd 500G \ + --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3) +SHORT_ARGS=(--total-disk 2Ti --vr1-dc0-nodes 3 --dc1-per-node-osd 500G \ + --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3) # --- constructed numeric example: size=3 + overhead FITS -------------------- # DC1 = 3*500G = 1.46TiB, DC2 same = 1.46TiB, combined ~2.92TiB, +30% overhead @@ -45,21 +45,21 @@ run 2 'REQUIRED' "T7 all args missing FAILS loud (rc 2)" run 2 '\-\-backup-overhead-fraction is REQUIRED' \ "T8 backup-overhead-fraction has NO default -- missing it alone FAILS loud" \ - --total-disk 10Ti --dc1-nodes 3 --dc1-per-node-osd 500G --dc2-nodes 3 --dc2-per-node-osd 500G + --total-disk 10Ti --vr1-dc0-nodes 3 --dc1-per-node-osd 500G --vr1-dc1-nodes 3 --dc2-per-node-osd 500G run 2 '\-\-total-disk is REQUIRED' "T9 missing --total-disk alone FAILS loud" \ - --dc1-nodes 3 --dc1-per-node-osd 500G --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 + --vr1-dc0-nodes 3 --dc1-per-node-osd 500G --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 # --- bad value formats fail loud -------------------------------------------- -run 2 'positive integer' "T10 non-numeric --dc1-nodes FAILS loud (rc 2)" \ - --total-disk 10Ti --dc1-nodes abc --dc1-per-node-osd 500G --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 +run 2 'positive integer' "T10 non-numeric --vr1-dc0-nodes FAILS loud (rc 2)" \ + --total-disk 10Ti --vr1-dc0-nodes abc --dc1-per-node-osd 500G --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 run 2 'non-negative decimal' "T11 non-numeric --backup-overhead-fraction FAILS loud (rc 2)" \ - --total-disk 10Ti --dc1-nodes 3 --dc1-per-node-osd 500G --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction abc + --total-disk 10Ti --vr1-dc0-nodes 3 --dc1-per-node-osd 500G --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction abc run 2 'integer byte count or Ki/Mi/Gi/Ti' "T12 bad size suffix on --total-disk FAILS loud (rc 2)" \ - --total-disk 10XB --dc1-nodes 3 --dc1-per-node-osd 500G --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 + --total-disk 10XB --vr1-dc0-nodes 3 --dc1-per-node-osd 500G --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 # --- size parsing: plain bytes and 1024-based suffixes agree ---------------- run 0 'verdict=PASS' "T13 plain-byte total-disk equivalent to 10Ti still PASSes" \ - --total-disk 10995116277760 --dc1-nodes 3 --dc1-per-node-osd 500G --dc2-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 + --total-disk 10995116277760 --vr1-dc0-nodes 3 --dc1-per-node-osd 500G --vr1-dc1-nodes 3 --dc2-per-node-osd 500G --backup-overhead-fraction 0.3 # --- --help works, exits 0, cites Section 3 / D-101 and the no-invented-fraction rule --- run 0 'Section 3' "T14 --help exits 0 and cites buildout-design Section 3" --help diff --git a/tests/dc-dc-dr-drill/run-tests.sh b/tests/dc-dc-dr-drill/run-tests.sh index fb42b11..1bd4664 100644 --- a/tests/dc-dc-dr-drill/run-tests.sh +++ b/tests/dc-dc-dr-drill/run-tests.sh @@ -32,31 +32,31 @@ # --- failover arg parsing --- run 1 '\-\-dc is required' "T4 failover missing --dc FAILS" failover -run 1 '\-\-dc must be dc1 or dc2' "T5 failover bad --dc token FAILS" failover --dc bogus --unit ceph-mon/0 --pool glance -run 1 '\-\-unit is required' "T6 failover missing --unit FAILS" failover --dc dc1 -run 1 'never assume .glance.' "T7 failover missing --pool FAILS, cites no-assume-glance" failover --dc dc1 --unit ceph-mon/0 +run 1 '\-\-dc must be vr1-dc0 or vr1-dc1' "T5 failover bad --dc token FAILS" failover --dc bogus --unit ceph-mon/0 --pool glance +run 1 '\-\-unit is required' "T6 failover missing --unit FAILS" failover --dc vr1-dc0 +run 1 'never assume .glance.' "T7 failover missing --pool FAILS, cites no-assume-glance" failover --dc vr1-dc0 --unit ceph-mon/0 # --- failback arg parsing --- run 1 '\-\-pool is required' "T8 failback missing --pool FAILS" failback run 1 '\-\-recovering-dc is required' "T9 failback missing --recovering-dc FAILS" failback --pool glance -run 1 '\-\-primary-dc is required' "T10 failback missing --primary-dc FAILS" failback --pool glance --recovering-dc dc1 -run 1 'must differ' "T11 failback recovering-dc == primary-dc FAILS" failback --pool glance --recovering-dc dc1 --primary-dc dc1 --recovering-unit u/0 --primary-unit u/1 -run 1 '\-\-recovering-unit is required' "T12 failback missing --recovering-unit FAILS" failback --pool glance --recovering-dc dc1 --primary-dc dc2 -run 1 '\-\-primary-unit is required' "T13 failback missing --primary-unit FAILS" failback --pool glance --recovering-dc dc1 --primary-dc dc2 --recovering-unit u/0 +run 1 '\-\-primary-dc is required' "T10 failback missing --primary-dc FAILS" failback --pool glance --recovering-dc vr1-dc0 +run 1 'must differ' "T11 failback recovering-dc == primary-dc FAILS" failback --pool glance --recovering-dc vr1-dc0 --primary-dc vr1-dc0 --recovering-unit u/0 --primary-unit u/1 +run 1 '\-\-recovering-unit is required' "T12 failback missing --recovering-unit FAILS" failback --pool glance --recovering-dc vr1-dc0 --primary-dc vr1-dc1 +run 1 '\-\-primary-unit is required' "T13 failback missing --primary-unit FAILS" failback --pool glance --recovering-dc vr1-dc0 --primary-dc vr1-dc1 --recovering-unit u/0 # --- failover: --confirmed-down gates --apply, but not the dry-run plan print --- -FO_DRY="$(bash "$SCRIPT" failover --dc dc1 --unit ceph-mon/0 --pool glance 2>&1)"; FO_DRY_RC=$? +FO_DRY="$(bash "$SCRIPT" failover --dc vr1-dc0 --unit ceph-mon/0 --pool glance 2>&1)"; FO_DRY_RC=$? [[ "$FO_DRY_RC" == 0 ]] && grep -q 'promote --force glance' <<<"$FO_DRY" && grep -q 'confirmed-down is required' <<<"$FO_DRY" \ && { echo " PASS T14 failover dry-run (no --confirmed-down) still prints the plan, rc 0"; PASS=$((PASS+1)); } || { echo " FAIL T14"; echo "$FO_DRY" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -run 1 'confirmed-down is required' "T15 failover --apply WITHOUT --confirmed-down is BLOCKED (rc 1)" failover --dc dc1 --unit ceph-mon/0 --pool glance --apply +run 1 'confirmed-down is required' "T15 failover --apply WITHOUT --confirmed-down is BLOCKED (rc 1)" failover --dc vr1-dc0 --unit ceph-mon/0 --pool glance --apply -FO_REREG="$(bash "$SCRIPT" failover --dc dc1 --unit ceph-mon/0 --pool glance --confirmed-down --reregister-image img1 2>&1)" +FO_REREG="$(bash "$SCRIPT" failover --dc vr1-dc0 --unit ceph-mon/0 --pool glance --confirmed-down --reregister-image img1 2>&1)" grep -q 'NEVER EXECUTED BY THIS SCRIPT' <<<"$FO_REREG" && grep -q 'img1' <<<"$FO_REREG" \ && { echo " PASS T16 failover re-registration step is printed but flagged manual (never auto-executed)"; PASS=$((PASS+1)); } || { echo " FAIL T16"; echo "$FO_REREG" | sed 's/^/ /'; FAIL=$((FAIL+1)); } # --- failback: the safety-critical step ORDER, verified by LINE NUMBER --- -FB_OUT="$(bash "$SCRIPT" failback --pool glance --recovering-dc dc1 --recovering-unit rec/0 --primary-dc dc2 --primary-unit pri/0 2>&1)"; FB_RC=$? +FB_OUT="$(bash "$SCRIPT" failback --pool glance --recovering-dc vr1-dc0 --recovering-unit rec/0 --primary-dc vr1-dc1 --primary-unit pri/0 2>&1)"; FB_RC=$? [[ "$FB_RC" == 0 ]] && { echo " PASS T17 failback dry-run (dc1 recovering, dc2 primary) exits 0"; PASS=$((PASS+1)); } || { echo " FAIL T17"; echo "$FB_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } L_112=$(grep -n '11.2 (demote recovering' <<<"$FB_OUT" | head -1 | cut -d: -f1) @@ -72,21 +72,21 @@ && grep -A0 '11.4b' <<<"$FB_OUT" | grep -q 'promote glance' \ && { echo " PASS T19 11.4a is a demote, 11.4b is a promote (not swapped)"; PASS=$((PASS+1)); } || { echo " FAIL T19"; echo "$FB_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -FB_SKIP="$(bash "$SCRIPT" failback --pool glance --recovering-dc dc1 --recovering-unit rec/0 --primary-dc dc2 --primary-unit pri/0 --skip-11-4 2>&1)" +FB_SKIP="$(bash "$SCRIPT" failback --pool glance --recovering-dc vr1-dc0 --recovering-unit rec/0 --primary-dc vr1-dc1 --primary-unit pri/0 --skip-11-4 2>&1)" grep -q '11.4 SKIPPED' <<<"$FB_SKIP" && ! grep -q '11.4a' <<<"$FB_SKIP" \ && { echo " PASS T20 --skip-11-4 stops the plan after 11.2/11.3"; PASS=$((PASS+1)); } || { echo " FAIL T20"; echo "$FB_SKIP" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -# --- $DC gate: informational in dry-run (dc2 involved), blocking before --apply --- -grep -q 'gate: FAILED' <<<"$FB_OUT" && grep -q 'gate: OK (dc1)' <<<"$FB_OUT" \ - && { echo " PASS T21 failback dry-run reports dc1 OK / dc2 FAILED, still prints full plan"; PASS=$((PASS+1)); } || { echo " FAIL T21"; echo "$FB_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } +# --- $DC gate: informational in dry-run (vr1-dc1 involved), blocking before --apply --- +grep -q 'gate: FAILED' <<<"$FB_OUT" && grep -q 'gate: OK (vr1-dc0)' <<<"$FB_OUT" \ + && { echo " PASS T21 failback dry-run reports vr1-dc0 OK / vr1-dc1 FAILED, still prints full plan"; PASS=$((PASS+1)); } || { echo " FAIL T21"; echo "$FB_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -run 3 '\$DC gate refused' "T22 failback --apply is BLOCKED by the dc2 gate (rc 3)" failback --pool glance --recovering-dc dc1 --recovering-unit rec/0 --primary-dc dc2 --primary-unit pri/0 --apply --no-prompt +run 3 '\$DC gate refused' "T22 failback --apply is BLOCKED by the dc2 gate (rc 3)" failback --pool glance --recovering-dc vr1-dc0 --recovering-unit rec/0 --primary-dc vr1-dc1 --primary-unit pri/0 --apply --no-prompt # --- --apply never runs without --apply: no juju invocation attempted in dry-run --- if command -v juju >/dev/null 2>&1; then echo " SKIP T23 juju-missing case (juju IS present in this environment -- can't exercise the missing-tool guard here)" else - run 2 'juju required on PATH' "T23 failover --apply (dc1 gate OK) but juju absent FAILS (rc 2)" failover --dc dc1 --unit ceph-mon/0 --pool glance --confirmed-down --apply + run 2 'juju required on PATH' "T23 failover --apply (dc1 gate OK) but juju absent FAILS (rc 2)" failover --dc vr1-dc0 --unit ceph-mon/0 --pool glance --confirmed-down --apply fi echo; echo "RESULT: PASS=$PASS FAIL=$FAIL" diff --git a/tests/dc-dc-prefixes-import/test_logic.py b/tests/dc-dc-prefixes-import/test_logic.py index 464efdb..d780669 100644 --- a/tests/dc-dc-prefixes-import/test_logic.py +++ b/tests/dc-dc-prefixes-import/test_logic.py @@ -115,31 +115,31 @@ # ----------------------------------------------------------------------------- # validate_dc1_supernet # ----------------------------------------------------------------------------- -dc2_super = expect_ok("second-DC supernet valid /19 no overlap", T.validate_dc1_supernet, "10.12.64.0/19") -expect_dies("second-DC supernet too small (/20) rejected", T.validate_dc1_supernet, "10.12.64.0/20") -expect_dies("second-DC supernet overlapping VR1 DC0 rejected", T.validate_dc1_supernet, "10.12.0.0/19") -expect_dies("second-DC supernet IPv6 input rejected", T.validate_dc1_supernet, "fd00::/19") +dc2_super = expect_ok("second-DC supernet valid /19 no overlap", T.validate_vr1_dc1_supernet, "10.12.64.0/19") +expect_dies("second-DC supernet too small (/20) rejected", T.validate_vr1_dc1_supernet, "10.12.64.0/20") +expect_dies("second-DC supernet overlapping VR1 DC0 rejected", T.validate_vr1_dc1_supernet, "10.12.0.0/19") +expect_dies("second-DC supernet IPv6 input rejected", T.validate_vr1_dc1_supernet, "fd00::/19") # ----------------------------------------------------------------------------- # carve_dc1_v4 # ----------------------------------------------------------------------------- if dc2_super is not None: - v4 = T.carve_dc1_v4(dc2_super) - check(set(v4.keys()) == set(T.PLANE_ORDER), "carve_dc1_v4 covers all six planes") + v4 = T.carve_vr1_dc1_v4(dc2_super) + check(set(v4.keys()) == set(T.PLANE_ORDER), "carve_vr1_dc1_v4 covers all six planes") all_22 = all(ipaddress.ip_network(c).prefixlen == 22 for c in v4.values()) - check(all_22, "carve_dc1_v4 all /22") + check(all_22, "carve_vr1_dc1_v4 all /22") within = all(ipaddress.ip_network(c).subnet_of(dc2_super) for c in v4.values()) - check(within, "carve_dc1_v4 all within supernet") + check(within, "carve_vr1_dc1_v4 all within supernet") distinct = len(set(v4.values())) == len(v4) - check(distinct, "carve_dc1_v4 all distinct") + check(distinct, "carve_vr1_dc1_v4 all distinct") # ----------------------------------------------------------------------------- # carve_v6 (D-111 NN layout: /60+/64 per plane, deployed net-byte mnemonic) # ----------------------------------------------------------------------------- org_ula = ipaddress.ip_network("fd12:3456:789a::/48") gua_dc0 = ipaddress.ip_network("2602:f3e2:f02::/48") -recs1 = T.carve_v6("dc0", org_ula, gua_dc0) -recs2 = T.carve_v6("dc1", org_ula, ipaddress.ip_network("2602:f3e2:f03::/48")) +recs1 = T.carve_v6("vr1-dc0", org_ula, gua_dc0) +recs2 = T.carve_v6("vr1-dc1", org_ula, ipaddress.ip_network("2602:f3e2:f03::/48")) # structure: 12 records (provider 3, metal-admin 2, metal-internal 1, data/storage/repl 2 each) check(len(recs1) == 12, "carve_v6 VR1-DC0 yields 12 records", str(len(recs1))) @@ -175,10 +175,10 @@ ipaddress.ip_network(a).overlaps(ipaddress.ip_network(b)) for a, _, _ in recs1 for b, _, _ in recs2 ) -check(no_overlap, "carve_v6 dc1/dc2 non-overlapping") +check(no_overlap, "carve_v6 vr1-dc0/vr1-dc1 non-overlapping") # DOCFIX-181 no-hang property preserved: a /40 GUA computes by math, not enumeration -recs_big = T.carve_v6("dc1", org_ula, ipaddress.ip_network("2602:f3e2:1000::/40")) +recs_big = T.carve_v6("vr1-dc1", org_ula, ipaddress.ip_network("2602:f3e2:1000::/40")) check(len(recs_big) == 12, "carve_v6 with a /40 GUA completes fast (no subnet enumeration)") # ----------------------------------------------------------------------------- @@ -218,14 +218,14 @@ fake1 = fake_pynetbox.FakeApi() fake1.preseed_roles(T.PLANE_ORDER) os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f02::/48" # apex: vr1-dc0 -os.environ.pop("DC2_V4_SUPERNET", None) # retired by D-117 -os.environ.pop("DC1_V4_SUPERNET", None) +for _v in ("DC1_V4_SUPERNET", "DC2_V4_SUPERNET", "VR1_DC1_V4_SUPERNET"): + os.environ.pop(_v, None) # both old names RETIRED by D-119 # --- D-117: DRY RUN IS THE DEFAULT. This is the guard that matters most: the # --- tool used to write with no flag at all, straight into the IPAM apex. with captured_stdout() as out0: - rc0 = run_main(["--dc", "dc0"], fake1) -check(rc0 == 0, "main dc0 DRY RUN rc==0", str(rc0)) + rc0 = run_main(["--dc", "vr1-dc0"], fake1) +check(rc0 == 0, "main vr1-dc0 DRY RUN rc==0", str(rc0)) check(out0.getvalue().count("CREATED prefix") == 0, "D-117: dry run (no --commit) CREATES NOTHING") check(out0.getvalue().count("[dry-run] would CREATE prefix") == 18, @@ -240,21 +240,21 @@ # --- now commit for real with captured_stdout() as out1: - rc1 = run_main(["--dc", "dc0", "--commit"], fake1) -check(rc1 == 0, "main dc0 --commit rc==0", str(rc1)) + rc1 = run_main(["--dc", "vr1-dc0", "--commit"], fake1) +check(rc1 == 0, "main vr1-dc0 --commit rc==0", str(rc1)) created_count = out1.getvalue().count("CREATED prefix") -check(created_count == 18, "main dc0 --commit creates 18 prefixes (6 v4 + 12 v6)", str(created_count)) -check("vr0-dc0" in out1.getvalue(), "main dc0 prints the vr0-dc0 (VR0 rehearsal) collision note") +check(created_count == 18, "main vr1-dc0 --commit creates 18 prefixes (6 v4 + 12 v6)", str(created_count)) +check("vr0-dc0" in out1.getvalue(), "main vr1-dc0 prints the vr0-dc0 (VR0 rehearsal) collision note") dc0_site = fake1.dcim.sites.get(slug="vr1-dc0") -check(dc0_site is not None, "D-117: --dc dc0 created the APEX site vr1-dc0 (NOT vr1-dc1)") +check(dc0_site is not None, "D-119: --dc vr1-dc0 created the APEX site vr1-dc0 (identity)") check(fake1.dcim.sites.get(slug="vr1-dc1") is None, - "D-117: --dc dc0 did NOT touch vr1-dc1 -- the off-by-one is dead") + "D-119: --dc vr1-dc0 did NOT touch vr1-dc1 -- the off-by-one is dead") with captured_stdout() as out2: - rc2 = run_main(["--dc", "dc0", "--commit"], fake1) -check(rc2 == 0, "main dc0 second run (idempotent) rc==0") -check(out2.getvalue().count("CREATED prefix") == 0, "main dc0 second run creates nothing new") -check(out2.getvalue().count("EXISTS prefix") == 18, "main dc0 second run reports all 18 as EXISTS") + rc2 = run_main(["--dc", "vr1-dc0", "--commit"], fake1) +check(rc2 == 0, "main vr1-dc0 second run (idempotent) rc==0") +check(out2.getvalue().count("CREATED prefix") == 0, "main vr1-dc0 second run creates nothing new") +check(out2.getvalue().count("EXISTS prefix") == 18, "main vr1-dc0 second run reports all 18 as EXISTS") # ----------------------------------------------------------------------------- # main() end-to-end -- VR1 DC1 (the SECOND DC): fresh site, distinct v4/GUA @@ -262,40 +262,40 @@ fake2 = fake_pynetbox.FakeApi() fake2.preseed_roles(T.PLANE_ORDER) os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f03::/48" # apex: vr1-dc1 -os.environ["DC1_V4_SUPERNET"] = "10.12.64.0/19" # D-115 +os.environ["VR1_DC1_V4_SUPERNET"] = "10.12.64.0/19" # D-115 with captured_stdout() as out3: - rc3 = run_main(["--dc", "dc1", "--commit"], fake2) -check(rc3 == 0, "main dc1 (second DC) run rc==0", str(rc3)) -check(out3.getvalue().count("CREATED prefix") == 18, "main dc1 creates 18 prefixes", + rc3 = run_main(["--dc", "vr1-dc1", "--commit"], fake2) +check(rc3 == 0, "main vr1-dc1 (second DC) run rc==0", str(rc3)) +check(out3.getvalue().count("CREATED prefix") == 18, "main vr1-dc1 creates 18 prefixes", str(out3.getvalue().count("CREATED prefix"))) -check(fake2.dcim.sites.get(slug="vr1-dc1") is not None, "main dc1 binds to the APEX site vr1-dc1") -check(fake2.dcim.sites.get(slug="vr1-dc2") is None, "D-117: the invented vr1-dc2 site is NEVER created") +check(fake2.dcim.sites.get(slug="vr1-dc1") is not None, "main vr1-dc1 binds to the APEX site vr1-dc1") +check(fake2.dcim.sites.get(slug="vr1-dc2") is None, "D-119: no phantom vr1-dc2 site is ever created") # ----------------------------------------------------------------------------- # main() -- second DC with DC1_V4_SUPERNET missing FAILS LOUD # ----------------------------------------------------------------------------- -del os.environ["DC1_V4_SUPERNET"] +del os.environ["VR1_DC1_V4_SUPERNET"] fake3 = fake_pynetbox.FakeApi() fake3.preseed_roles(T.PLANE_ORDER) try: with captured_stdout(): - run_main(["--dc", "dc1", "--commit"], fake3) - no("main dc1 without DC1_V4_SUPERNET fails loud", "did not raise SystemExit") + run_main(["--dc", "vr1-dc1", "--commit"], fake3) + no("main vr1-dc1 without VR1_DC1_V4_SUPERNET fails loud", "did not raise SystemExit") except SystemExit: - ok("main dc1 without DC1_V4_SUPERNET fails loud") + ok("main vr1-dc1 without VR1_DC1_V4_SUPERNET fails loud") # ----------------------------------------------------------------------------- -# D-117: the RETIRED env var name is rejected, not silently ignored +# D-119: BOTH retired env var names are rejected, not silently ignored # ----------------------------------------------------------------------------- os.environ["DC2_V4_SUPERNET"] = "10.12.64.0/19" # muscle memory os.environ.pop("DC1_V4_SUPERNET", None) try: with captured_stdout(): - run_main(["--dc", "dc1", "--commit"], fake_pynetbox.FakeApi()) - no("D-117: retired DC2_V4_SUPERNET is rejected by name") + run_main(["--dc", "vr1-dc1", "--commit"], fake_pynetbox.FakeApi()) + no("D-119: retired DC2_V4_SUPERNET is rejected by name") except SystemExit: - ok("D-117: retired DC2_V4_SUPERNET is rejected by name") + ok("D-119: retired DC2_V4_SUPERNET is rejected by name") os.environ.pop("DC2_V4_SUPERNET", None) # ----------------------------------------------------------------------------- @@ -303,7 +303,7 @@ # ----------------------------------------------------------------------------- fake4 = fake_pynetbox.FakeApi() fake4.preseed_roles(T.PLANE_ORDER) -os.environ["DC1_V4_SUPERNET"] = "10.12.64.0/19" +os.environ["VR1_DC1_V4_SUPERNET"] = "10.12.64.0/19" try: with captured_stdout(): @@ -312,12 +312,15 @@ except SystemExit: ok("main missing --dc fails loud") -try: - with captured_stdout(): - run_main(["--dc", "dc2"], fake4) - no("D-117: --dc dc2 (the old off-by-one label) is REJECTED") -except SystemExit: - ok("D-117: --dc dc2 (the old off-by-one label) is REJECTED") +# D-119: EVERY bare dcN is now an invalid choice. They were ambiguous across +# regions ('dc0' = VR0's live cloud in lib-net.sh, VR1's first DC here). +for _bare in ("dc0", "dc1", "dc2"): + try: + with captured_stdout(): + run_main(["--dc", _bare], fake4) + no(f"D-119: bare --dc {_bare} is REJECTED (retired, cross-region ambiguous)") + except SystemExit: + ok(f"D-119: bare --dc {_bare} is REJECTED (retired, cross-region ambiguous)") try: with captured_stdout(): @@ -327,6 +330,60 @@ ok("main invalid --dc choice fails loud") # ----------------------------------------------------------------------------- +# D-119 BREAK-2: DC_GUA_PREFIX must AGREE with --dc. +# +# Without this guard, `--dc vr1-dc0 DC_GUA_PREFIX=` was ACCEPTED: it +# writes the SECOND DC's GUA prefixes, carved with the FIRST DC's ULA nibble +# (DC_V6_INDEX is keyed off --dc, independently), all scoped to the FIRST DC's +# site. A silently mis-bound datacenter assembled from two disagreeing sources. +# Identity-mapping the site slug does NOT close this -- it is one layer down. +# ----------------------------------------------------------------------------- +fake5 = fake_pynetbox.FakeApi() +fake5.preseed_roles(T.PLANE_ORDER) +os.environ["ORG_ULA_48"] = "fd50:840e:74e2::/48" +os.environ["VR1_DC1_V4_SUPERNET"] = "10.12.64.0/19" + +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f03::/48" # the SECOND DC's block... +try: + with captured_stdout(): + run_main(["--dc", "vr1-dc0", "--commit"], fake5) # ...but the FIRST DC's site + no("D-119 BREAK-2: mismatched DC_GUA_PREFIX/--dc is REJECTED") +except SystemExit: + ok("D-119 BREAK-2: mismatched DC_GUA_PREFIX/--dc is REJECTED") +check(fake5.dcim.sites.get(slug="vr1-dc0") is None, + "D-119 BREAK-2: the mismatched run wrote NOTHING (no site created)") + +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f02::/48" # the FIRST DC's block... +try: + with captured_stdout(): + run_main(["--dc", "vr1-dc1", "--commit"], fake_pynetbox.FakeApi()) # ...second DC's site + no("D-119 BREAK-2: the reverse mismatch is REJECTED too") +except SystemExit: + ok("D-119 BREAK-2: the reverse mismatch is REJECTED too") + +# The MATCHING pair still works -- the guard must not be a blanket refusal. +fake6 = fake_pynetbox.FakeApi() +fake6.preseed_roles(T.PLANE_ORDER) +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f02::/48" +with captured_stdout(): + rc_ok = run_main(["--dc", "vr1-dc0", "--commit"], fake6) +check(rc_ok == 0, "D-119 BREAK-2: the MATCHING --dc/DC_GUA_PREFIX pair still succeeds") + +# ----------------------------------------------------------------------------- +# D-119 BREAK-1: descriptions must not munge the selector into the label. +# +# The old code did f"VR1 {dc.upper()} ..." -- fine when dc was "dc0", but under +# D-119 dc is "vr1-dc0", which renders "VR1 VR1-DC0 provider-public". Same defect +# class as the original bug: DERIVING a label from a token instead of looking it +# up. It hid in the description field, where slug-focused review missed it. +# ----------------------------------------------------------------------------- +descs = [p.description for p in fake6.ipam.prefixes.all()] +check(any(d.startswith("VR1 DC0 ") for d in descs), + "D-119 BREAK-1: descriptions read 'VR1 DC0 ...' (from SITES[name], looked up)") +check(not any("VR1-DC0" in d.upper().replace("VR1 DC0", "") for d in descs), + "D-119 BREAK-1: no description contains the munged 'VR1 VR1-DC0'") + +# ----------------------------------------------------------------------------- # Summary # ----------------------------------------------------------------------------- print() @@ -337,22 +394,28 @@ # WROTE BY DEFAULT, so it would have landed silently in the IPAM apex. # The slugs below are the APEX's, measured live 2026-07-13. Do not "fix" them. # --------------------------------------------------------------------------- -check(T.SITES["dc0"]["slug"] == "vr1-dc0", "D-117: --dc dc0 binds to site vr1-dc0") -check(T.SITES["dc1"]["slug"] == "vr1-dc1", "D-117: --dc dc1 binds to site vr1-dc1") -check("dc2" not in T.SITES, "D-117: the off-by-one 'dc2' selector is GONE") -check(T.DC_V6_INDEX["dc0"] == 0x02, "D-117: VR1 DC0 -> GUA site nibble 02 (== apex vr1-dc0 f02::/48)") -check(T.DC_V6_INDEX["dc1"] == 0x03, "D-117: VR1 DC1 -> GUA site nibble 03 (== apex vr1-dc1 f03::/48)") +# THE D-119 STRUCTURAL INVARIANT: the selector IS the slug. If a future edit +# reintroduces an offset table, this check fails -- the bug becomes untypeable. +check(all(k == v["slug"] for k, v in T.SITES.items()), + "D-119: SITES is an IDENTITY map -- the --dc selector IS the apex site slug") +check(set(T.SITES) == {"vr1-dc0", "vr1-dc1"}, "D-119: only the two apex slugs are selectable") +check(not any(k in T.SITES for k in ("dc0", "dc1", "dc2")), + "D-119: every bare dcN selector is GONE from SITES") +check(T.DC_V6_INDEX["vr1-dc0"] == 0x02, "D-119: vr1-dc0 -> GUA site nibble 02 (== apex f02::/48)") +check(T.DC_V6_INDEX["vr1-dc1"] == 0x03, "D-119: vr1-dc1 -> GUA site nibble 03 (== apex f03::/48)") +check(T.EXPECTED_GUA == {"vr1-dc0": "2602:f3e2:f02::/48", "vr1-dc1": "2602:f3e2:f03::/48"}, + "D-119: EXPECTED_GUA pins the apex's MEASURED DC->GUA binding") # dry-by-default: --commit must exist, and the writers must take a commit flag. import inspect _src = inspect.getsource(T) -check("--commit" in _src, "D-117: --commit flag exists") +check("--commit" in _src, "dry-by-default: --commit flag exists") check("commit" in inspect.signature(T.create_or_report_prefix).parameters, - "D-117: create_or_report_prefix is commit-gated") + "dry-by-default: create_or_report_prefix is commit-gated") check("commit" in inspect.signature(T.find_or_create_site).parameters, - "D-117: find_or_create_site is commit-gated (a site is a WRITE)") -check("DC2_V4_SUPERNET" in _src and "RETIRED by D-117" in _src, - "D-117: the retired DC2_V4_SUPERNET env var is rejected by name, not ignored") + "dry-by-default: find_or_create_site is commit-gated (a site is a WRITE)") +check("DC2_V4_SUPERNET" in _src and "DC1_V4_SUPERNET" in _src and "RETIRED (D-119)" in _src, + "D-119: BOTH retired supernet env vars are rejected by name, not ignored") if F == 0: print(f"ALL PASS ({P} checks)") diff --git a/tests/dc-dc-radosgw-multisite/run-tests.sh b/tests/dc-dc-radosgw-multisite/run-tests.sh index 1f36502..e570c14 100644 --- a/tests/dc-dc-radosgw-multisite/run-tests.sh +++ b/tests/dc-dc-radosgw-multisite/run-tests.sh @@ -39,33 +39,33 @@ run 1 'unknown subcommand' "T2 bogus subcommand FAILS (rc 1)" bogus run 0 'usage:' "T3 --help exits 0 with usage" --help run 1 '\-\-dc is required' "T4 master-init missing --dc FAILS (rc 1)" master-init -run 1 '\-\-dc must be dc1 or dc2' "T5 master-init bad --dc token FAILS" master-init --dc bogus --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z --endpoint http://x:80 -run 1 '\-\-unit is required' "T6 master-init missing --unit FAILS" master-init --dc dc1 -run 1 '\-\-realm is required' "T7 master-init missing --realm FAILS" master-init --dc dc1 --unit ceph-radosgw/0 --zonegroup ZG --zone Z --endpoint http://x:80 -run 1 '\-\-access-key is required' "T8 join-readonly missing --access-key FAILS" join-readonly --dc dc1 --unit ceph-radosgw/0 --zonegroup ZG --zone Z --endpoint http://x:80 --secret S -run 1 '\-\-secret is required' "T9 join-readonly missing --secret FAILS" join-readonly --dc dc1 --unit ceph-radosgw/0 --zonegroup ZG --zone Z --endpoint http://x:80 --access-key K -run 1 '\-\-zone is required' "T10 enable-two-way missing --zone FAILS" enable-two-way --dc dc1 --unit ceph-radosgw/0 +run 1 '\-\-dc must be vr1-dc0 or vr1-dc1' "T5 master-init bad --dc token FAILS" master-init --dc bogus --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z --endpoint http://x:80 +run 1 '\-\-unit is required' "T6 master-init missing --unit FAILS" master-init --dc vr1-dc0 +run 1 '\-\-realm is required' "T7 master-init missing --realm FAILS" master-init --dc vr1-dc0 --unit ceph-radosgw/0 --zonegroup ZG --zone Z --endpoint http://x:80 +run 1 '\-\-access-key is required' "T8 join-readonly missing --access-key FAILS" join-readonly --dc vr1-dc0 --unit ceph-radosgw/0 --zonegroup ZG --zone Z --endpoint http://x:80 --secret S +run 1 '\-\-secret is required' "T9 join-readonly missing --secret FAILS" join-readonly --dc vr1-dc0 --unit ceph-radosgw/0 --zonegroup ZG --zone Z --endpoint http://x:80 --access-key K +run 1 '\-\-zone is required' "T10 enable-two-way missing --zone FAILS" enable-two-way --dc vr1-dc0 --unit ceph-radosgw/0 # --- dry-run plan content (dc1, gate OK) --- -MI_OUT="$(bash "$SCRIPT" master-init --dc dc1 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://10.12.36.10:80 2>&1)"; MI_RC=$? +MI_OUT="$(bash "$SCRIPT" master-init --dc vr1-dc0 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://10.12.36.10:80 2>&1)"; MI_RC=$? [[ "$MI_RC" == 0 ]] && grep -q 'OK (dry-run)' <<<"$MI_OUT" && { echo " PASS T11 master-init dry-run rc 0 + OK marker"; PASS=$((PASS+1)); } || { echo " FAIL T11"; FAIL=$((FAIL+1)); } -grep -q 'gate: OK (dc1)' <<<"$MI_OUT" && { echo " PASS T12 dc1 gate reports OK"; PASS=$((PASS+1)); } || { echo " FAIL T12"; FAIL=$((FAIL+1)); } +grep -q 'gate: OK (vr1-dc0)' <<<"$MI_OUT" && { echo " PASS T12 vr1-dc0 gate reports OK"; PASS=$((PASS+1)); } || { echo " FAIL T12"; FAIL=$((FAIL+1)); } grep -qE 'realm create --rgw-realm=R' <<<"$MI_OUT" && grep -qE 'zonegroup create.*--master' <<<"$MI_OUT" && grep -qE 'zone create.*--master' <<<"$MI_OUT" && grep -q 'period update --commit' <<<"$MI_OUT" \ && { echo " PASS T13 master-init plan contains realm/zonegroup/zone/period steps"; PASS=$((PASS+1)); } || { echo " FAIL T13"; echo "$MI_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } grep -q 'no --restart-action given' <<<"$MI_OUT" && { echo " PASS T14 no-restart-action reminder present"; PASS=$((PASS+1)); } || { echo " FAIL T14"; FAIL=$((FAIL+1)); } -RESTART_OUT="$(bash "$SCRIPT" master-init --dc dc1 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 --restart-action restart 2>&1)" +RESTART_OUT="$(bash "$SCRIPT" master-init --dc vr1-dc0 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 --restart-action restart 2>&1)" grep -qE 'juju run ceph-radosgw/0 restart -m openstack' <<<"$RESTART_OUT" && { echo " PASS T15 --restart-action adds a juju run step"; PASS=$((PASS+1)); } || { echo " FAIL T15"; echo "$RESTART_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } # --- $DC gate: informational in dry-run, blocking before --apply --- -DC2_DRY="$(bash "$SCRIPT" master-init --dc dc2 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 2>&1)"; DC2_DRY_RC=$? +DC2_DRY="$(bash "$SCRIPT" master-init --dc vr1-dc1 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 2>&1)"; DC2_DRY_RC=$? [[ "$DC2_DRY_RC" == 0 ]] && grep -q 'gate: FAILED' <<<"$DC2_DRY" && grep -q 'OK (dry-run)' <<<"$DC2_DRY" \ && { echo " PASS T16 dc2 dry-run still prints the plan (gate is informational here)"; PASS=$((PASS+1)); } || { echo " FAIL T16"; echo "$DC2_DRY" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -run 3 '\$DC gate refused' "T17 dc2 --apply is BLOCKED by the gate (rc 3)" master-init --dc dc2 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 --apply +run 3 '\$DC gate refused' "T17 dc2 --apply is BLOCKED by the gate (rc 3)" master-init --dc vr1-dc1 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 --apply # --- secret redaction --- -JR_OUT="$(bash "$SCRIPT" join-readonly --dc dc1 --unit ceph-radosgw/0 --zonegroup ZG --zone Z2 --endpoint http://x:80 --access-key AK123 --secret TOPSECRETVALUE 2>&1)" +JR_OUT="$(bash "$SCRIPT" join-readonly --dc vr1-dc0 --unit ceph-radosgw/0 --zonegroup ZG --zone Z2 --endpoint http://x:80 --access-key AK123 --secret TOPSECRETVALUE 2>&1)" grep -q '' <<<"$JR_OUT" && ! grep -q 'TOPSECRETVALUE' <<<"$JR_OUT" \ && { echo " PASS T18 --secret value is redacted in printed plan"; PASS=$((PASS+1)); } || { echo " FAIL T18 (secret leaked or not redacted)"; echo "$JR_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } @@ -73,7 +73,7 @@ if command -v juju >/dev/null 2>&1; then echo " SKIP T19 juju-missing case (juju IS present in this environment -- can't exercise the missing-tool guard here)" else - run 2 'juju required on PATH' "T19 --apply with dc1 (gate OK) but juju absent FAILS (rc 2)" master-init --dc dc1 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 --apply + run 2 'juju required on PATH' "T19 --apply with dc1 (gate OK) but juju absent FAILS (rc 2)" master-init --dc vr1-dc0 --unit ceph-radosgw/0 --realm R --zonegroup ZG --zone Z1 --endpoint http://x:80 --apply fi echo; echo "RESULT: PASS=$PASS FAIL=$FAIL" diff --git a/tests/dc-dc-rbd-mirror/run-tests.sh b/tests/dc-dc-rbd-mirror/run-tests.sh index b4345ee..302c20f 100644 --- a/tests/dc-dc-rbd-mirror/run-tests.sh +++ b/tests/dc-dc-rbd-mirror/run-tests.sh @@ -28,40 +28,40 @@ run 1 'unknown subcommand' "T2 bogus subcommand FAILS (rc 1)" bogus run 0 'usage:' "T3 --help exits 0 with usage" --help run 1 '\-\-dc is required' "T4 bootstrap-primary missing --dc FAILS" bootstrap-primary -run 1 '\-\-dc must be dc1 or dc2' "T5 bootstrap-primary bad --dc token FAILS" bootstrap-primary --dc bogus --unit ceph-mon/0 --pool glance --site-name dc1 -run 1 '\-\-unit is required' "T6 bootstrap-primary missing --unit FAILS" bootstrap-primary --dc dc1 -run 1 'do not assume .glance.' "T7 bootstrap-primary missing --pool FAILS, cites no-assume-glance" bootstrap-primary --dc dc1 --unit ceph-mon/0 --site-name dc1 -run 1 '\-\-site-name is required' "T8 bootstrap-primary missing --site-name FAILS" bootstrap-primary --dc dc1 --unit ceph-mon/0 --pool glance -run 1 '\-\-direction is required' "T9 bootstrap-secondary missing --direction FAILS" bootstrap-secondary --dc dc1 --unit ceph-mon/0 --pool glance --site-name dc2 --token-in /tmp/tok -run 1 '\-\-direction must be rx-only or rx-tx' "T10 bootstrap-secondary bad --direction FAILS" bootstrap-secondary --dc dc1 --unit ceph-mon/0 --pool glance --site-name dc2 --direction bogus --token-in /tmp/tok -run 1 '\-\-token-in is required' "T11 bootstrap-secondary missing --token-in FAILS" bootstrap-secondary --dc dc1 --unit ceph-mon/0 --pool glance --site-name dc2 --direction rx-only +run 1 '\-\-dc must be vr1-dc0 or vr1-dc1' "T5 bootstrap-primary bad --dc token FAILS" bootstrap-primary --dc bogus --unit ceph-mon/0 --pool glance --site-name vr1-dc0 +run 1 '\-\-unit is required' "T6 bootstrap-primary missing --unit FAILS" bootstrap-primary --dc vr1-dc0 +run 1 'do not assume .glance.' "T7 bootstrap-primary missing --pool FAILS, cites no-assume-glance" bootstrap-primary --dc vr1-dc0 --unit ceph-mon/0 --site-name vr1-dc0 +run 1 '\-\-site-name is required' "T8 bootstrap-primary missing --site-name FAILS" bootstrap-primary --dc vr1-dc0 --unit ceph-mon/0 --pool glance +run 1 '\-\-direction is required' "T9 bootstrap-secondary missing --direction FAILS" bootstrap-secondary --dc vr1-dc0 --unit ceph-mon/0 --pool glance --site-name vr1-dc1 --token-in /tmp/tok +run 1 '\-\-direction must be rx-only or rx-tx' "T10 bootstrap-secondary bad --direction FAILS" bootstrap-secondary --dc vr1-dc0 --unit ceph-mon/0 --pool glance --site-name vr1-dc1 --direction bogus --token-in /tmp/tok +run 1 '\-\-token-in is required' "T11 bootstrap-secondary missing --token-in FAILS" bootstrap-secondary --dc vr1-dc0 --unit ceph-mon/0 --pool glance --site-name vr1-dc1 --direction rx-only # --- dry-run plan content --- -BP_OUT="$(bash "$SCRIPT" bootstrap-primary --dc dc1 --unit ceph-mon/0 --pool glance --site-name dc1 2>&1)"; BP_RC=$? +BP_OUT="$(bash "$SCRIPT" bootstrap-primary --dc vr1-dc0 --unit ceph-mon/0 --pool glance --site-name vr1-dc0 2>&1)"; BP_RC=$? [[ "$BP_RC" == 0 ]] && grep -q 'OK (dry-run)' <<<"$BP_OUT" && { echo " PASS T12 bootstrap-primary dry-run rc 0 + OK marker"; PASS=$((PASS+1)); } || { echo " FAIL T12"; echo "$BP_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -grep -q 'rbd mirror pool enable glance image' <<<"$BP_OUT" && grep -q 'peer bootstrap create --site-name dc1 glance' <<<"$BP_OUT" \ +grep -q 'rbd mirror pool enable glance image' <<<"$BP_OUT" && grep -q 'peer bootstrap create --site-name vr1-dc0 glance' <<<"$BP_OUT" \ && { echo " PASS T13 bootstrap-primary plan contains enable + peer-bootstrap-create"; PASS=$((PASS+1)); } || { echo " FAIL T13"; echo "$BP_OUT" | sed 's/^/ /'; FAIL=$((FAIL+1)); } grep -q 'transfer it to the DC2 unit OUT OF BAND' <<<"$BP_OUT" && { echo " PASS T14 bootstrap-primary reminds out-of-band token transfer"; PASS=$((PASS+1)); } || { echo " FAIL T14"; FAIL=$((FAIL+1)); } -BS_RXONLY="$(bash "$SCRIPT" bootstrap-secondary --dc dc1 --unit ceph-mon/1 --pool glance --site-name dc2 --direction rx-only --token-in /tmp/tok 2>&1)" -grep -q 'peer bootstrap import --site-name dc2 --direction rx-only glance /tmp/tok' <<<"$BS_RXONLY" \ +BS_RXONLY="$(bash "$SCRIPT" bootstrap-secondary --dc vr1-dc0 --unit ceph-mon/1 --pool glance --site-name vr1-dc1 --direction rx-only --token-in /tmp/tok 2>&1)" +grep -q 'peer bootstrap import --site-name vr1-dc1 --direction rx-only glance /tmp/tok' <<<"$BS_RXONLY" \ && { echo " PASS T15 bootstrap-secondary rx-only plan is correct"; PASS=$((PASS+1)); } || { echo " FAIL T15"; echo "$BS_RXONLY" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -BS_RXTX="$(bash "$SCRIPT" bootstrap-secondary --dc dc1 --unit ceph-mon/1 --pool glance --site-name dc2 --direction rx-tx --token-in /tmp/tok2 2>&1)" -grep -q 'peer bootstrap import --site-name dc2 --direction rx-tx glance /tmp/tok2' <<<"$BS_RXTX" \ +BS_RXTX="$(bash "$SCRIPT" bootstrap-secondary --dc vr1-dc0 --unit ceph-mon/1 --pool glance --site-name vr1-dc1 --direction rx-tx --token-in /tmp/tok2 2>&1)" +grep -q 'peer bootstrap import --site-name vr1-dc1 --direction rx-tx glance /tmp/tok2' <<<"$BS_RXTX" \ && { echo " PASS T16 bootstrap-secondary rx-tx (two-way) plan is correct"; PASS=$((PASS+1)); } || { echo " FAIL T16"; echo "$BS_RXTX" | sed 's/^/ /'; FAIL=$((FAIL+1)); } # --- $DC gate: informational in dry-run, blocking before --apply --- -DC2_DRY="$(bash "$SCRIPT" bootstrap-primary --dc dc2 --unit ceph-mon/0 --pool glance --site-name dc2 2>&1)"; DC2_DRY_RC=$? +DC2_DRY="$(bash "$SCRIPT" bootstrap-primary --dc vr1-dc1 --unit ceph-mon/0 --pool glance --site-name vr1-dc1 2>&1)"; DC2_DRY_RC=$? [[ "$DC2_DRY_RC" == 0 ]] && grep -q 'gate: FAILED' <<<"$DC2_DRY" && grep -q 'OK (dry-run)' <<<"$DC2_DRY" \ && { echo " PASS T17 dc2 dry-run still prints the plan (gate is informational here)"; PASS=$((PASS+1)); } || { echo " FAIL T17"; echo "$DC2_DRY" | sed 's/^/ /'; FAIL=$((FAIL+1)); } -run 3 '\$DC gate refused' "T18 dc2 --apply is BLOCKED by the gate (rc 3)" bootstrap-primary --dc dc2 --unit ceph-mon/0 --pool glance --site-name dc2 --apply +run 3 '\$DC gate refused' "T18 dc2 --apply is BLOCKED by the gate (rc 3)" bootstrap-primary --dc vr1-dc1 --unit ceph-mon/0 --pool glance --site-name vr1-dc1 --apply if command -v juju >/dev/null 2>&1; then echo " SKIP T19 juju-missing case (juju IS present in this environment -- can't exercise the missing-tool guard here)" else - run 2 'juju required on PATH' "T19 --apply with dc1 (gate OK) but juju absent FAILS (rc 2)" bootstrap-primary --dc dc1 --unit ceph-mon/0 --pool glance --site-name dc1 --apply + run 2 'juju required on PATH' "T19 --apply with dc1 (gate OK) but juju absent FAILS (rc 2)" bootstrap-primary --dc vr1-dc0 --unit ceph-mon/0 --pool glance --site-name vr1-dc0 --apply fi echo; echo "RESULT: PASS=$PASS FAIL=$FAIL" diff --git a/tests/dc-selector/run-tests.sh b/tests/dc-selector/run-tests.sh index 4c2caed..b745bd2 100644 --- a/tests/dc-selector/run-tests.sh +++ b/tests/dc-selector/run-tests.sh @@ -36,34 +36,52 @@ chk "HOSTS array unaffected" "${HOSTS[0]}" "openstack0" chk "HOST_OCTET unaffected" "${HOST_OCTET[openstack0]}" "40" -# --- lib_net_select_dc: dc0/dc1 no-op, dc2 fails loud, unknown fails loud --- -( lib_net_select_dc dc0 ); chk "net dc0 no-op rc" "$?" 0 -( lib_net_select_dc dc1 ); chk "net dc1 no-op rc" "$?" 0 -( lib_net_select_dc dc2 2>/dev/null ); chk "net dc2 fails-loud rc" "$?" 1 -NET_ERR="$(lib_net_select_dc dc2 2>&1 1>/dev/null || true)" -grep -q "NetBox" <<<"$NET_ERR" && ok "net dc2 error cites NetBox gap" || no "net dc2 error cites NetBox gap" +# --- lib_net_select_dc (D-119 region-qualified): vr0-dc0/vr1-dc0 no-op, +# vr1-dc1 fails loud, RETIRED bare dcN fails loud, unknown fails loud --- +( lib_net_select_dc vr0-dc0 ); chk "net vr0-dc0 no-op rc" "$?" 0 +( lib_net_select_dc vr1-dc0 ); chk "net vr1-dc0 no-op rc" "$?" 0 +( lib_net_select_dc vr1-dc1 2>/dev/null ); chk "net vr1-dc1 fails-loud rc" "$?" 1 +NET_ERR="$(lib_net_select_dc vr1-dc1 2>&1 1>/dev/null || true)" +grep -q "NetBox" <<<"$NET_ERR" && ok "net vr1-dc1 error cites NetBox gap" || no "net vr1-dc1 error cites NetBox gap" ( lib_net_select_dc bogus 2>/dev/null ); chk "net unknown-token fails-loud rc" "$?" 1 ( lib_net_select_dc 2>/dev/null ); chk "net missing-arg fails-loud rc" "$?" 1 -# no-op really means no-op: values identical after selecting either dc0 or dc1 -DC0_VAL="$(lib_net_select_dc dc0 >/dev/null 2>&1; echo "${PLANE_CIDRS[0]}")" -DC1_VAL="$(lib_net_select_dc dc1 >/dev/null 2>&1; echo "${PLANE_CIDRS[0]}")" -chk "net dc0/dc1 identical values (D-101)" "$DC0_VAL" "$DC1_VAL" +# D-119 REGRESSION GUARD: the bare dcN tokens are RETIRED and must be REJECTED. +# Accepting them "for compatibility" would preserve the exact cross-region +# ambiguity D-119 deletes -- 'dc0' meant VR0's LIVE cloud in lib-net.sh but VR1's +# FIRST DC in the NetBox importer. Silence here is how the off-by-one comes back. +for retired in dc0 dc1 dc2; do + ( lib_net_select_dc "$retired" 2>/dev/null ); chk "net RETIRED '$retired' rejected (D-119)" "$?" 1 + R_ERR="$(lib_net_select_dc "$retired" 2>&1 1>/dev/null || true)" + grep -q "RETIRED" <<<"$R_ERR" && ok "net '$retired' error says RETIRED" || no "net '$retired' error says RETIRED" +done -# --- lib_hosts_select_dc: dc0 no-op, dc1 AND dc2 both fail loud --- -( lib_hosts_select_dc dc0 ); chk "hosts dc0 no-op rc" "$?" 0 -( lib_hosts_select_dc dc1 2>/dev/null ); chk "hosts dc1 fails-loud rc" "$?" 1 -( lib_hosts_select_dc dc2 2>/dev/null ); chk "hosts dc2 fails-loud rc" "$?" 1 -HOSTS_ERR1="$(lib_hosts_select_dc dc1 2>&1 1>/dev/null || true)" -grep -q "no enrolled hosts" <<<"$HOSTS_ERR1" && ok "hosts dc1 error cites no-enrollment" || no "hosts dc1 error cites no-enrollment" -HOSTS_ERR2="$(lib_hosts_select_dc dc2 2>&1 1>/dev/null || true)" -grep -q "no enrolled hosts" <<<"$HOSTS_ERR2" && ok "hosts dc2 error cites no-enrollment" || no "hosts dc2 error cites no-enrollment" +# no-op really means no-op: values identical after selecting vr0-dc0 or vr1-dc0. +# They match by INHERITANCE (D-101: VR1's first DC inherits VR0 DC0's v4 layout), +# not by coincidence -- and they are separate case arms so they can diverge later. +VR0_VAL="$(lib_net_select_dc vr0-dc0 >/dev/null 2>&1; echo "${PLANE_CIDRS[0]}")" +VR1_VAL="$(lib_net_select_dc vr1-dc0 >/dev/null 2>&1; echo "${PLANE_CIDRS[0]}")" +chk "net vr0-dc0/vr1-dc0 identical values (D-101 inheritance)" "$VR0_VAL" "$VR1_VAL" + +# --- lib_hosts_select_dc: vr0-dc0 no-op, BOTH VR1 DCs fail loud --- +( lib_hosts_select_dc vr0-dc0 ); chk "hosts vr0-dc0 no-op rc" "$?" 0 +( lib_hosts_select_dc vr1-dc0 2>/dev/null ); chk "hosts vr1-dc0 fails-loud rc" "$?" 1 +( lib_hosts_select_dc vr1-dc1 2>/dev/null ); chk "hosts vr1-dc1 fails-loud rc" "$?" 1 +HOSTS_ERR1="$(lib_hosts_select_dc vr1-dc0 2>&1 1>/dev/null || true)" +grep -q "no enrolled hosts" <<<"$HOSTS_ERR1" && ok "hosts vr1-dc0 error cites no-enrollment" || no "hosts vr1-dc0 error cites no-enrollment" +HOSTS_ERR2="$(lib_hosts_select_dc vr1-dc1 2>&1 1>/dev/null || true)" +grep -q "no enrolled hosts" <<<"$HOSTS_ERR2" && ok "hosts vr1-dc1 error cites no-enrollment" || no "hosts vr1-dc1 error cites no-enrollment" ( lib_hosts_select_dc bogus 2>/dev/null ); chk "hosts unknown-token fails-loud rc" "$?" 1 ( lib_hosts_select_dc 2>/dev/null ); chk "hosts missing-arg fails-loud rc" "$?" 1 +for retired in dc0 dc1 dc2; do + ( lib_hosts_select_dc "$retired" 2>/dev/null ); chk "hosts RETIRED '$retired' rejected (D-119)" "$?" 1 +done -# --- the documented asymmetry itself: dc1 differs between the two libs --- -( lib_net_select_dc dc1 ); NET_DC1_RC=$? -( lib_hosts_select_dc dc1 2>/dev/null ); HOSTS_DC1_RC=$? -[ "$NET_DC1_RC" = 0 ] && [ "$HOSTS_DC1_RC" = 1 ] && ok "dc1 asymmetry: net no-ops, hosts fails (documented, not a bug)" || no "dc1 asymmetry broken (net=$NET_DC1_RC hosts=$HOSTS_DC1_RC)" +# --- the documented asymmetry: vr1-dc0 differs between the two libs --- +# net no-ops (D-101 says it inherits VR0 DC0's v4), hosts FAILS (it has no +# enrolled hosts yet). Intentional; asserted so nobody "fixes" one to match. +( lib_net_select_dc vr1-dc0 ); NET_RC=$? +( lib_hosts_select_dc vr1-dc0 2>/dev/null ); HOSTS_RC=$? +[ "$NET_RC" = 0 ] && [ "$HOSTS_RC" = 1 ] && ok "vr1-dc0 asymmetry: net no-ops, hosts fails (documented, not a bug)" || no "vr1-dc0 asymmetry broken (net=$NET_RC hosts=$HOSTS_RC)" echo; [ "$F" = 0 ] && { echo "ALL PASS ($P checks)"; exit 0; } || { echo "FAILURES: $F"; exit 1; } diff --git a/tests/phase-00-maas-standup/run-tests.sh b/tests/phase-00-maas-standup/run-tests.sh index 15a26df..91554a8 100644 --- a/tests/phase-00-maas-standup/run-tests.sh +++ b/tests/phase-00-maas-standup/run-tests.sh @@ -86,15 +86,15 @@ echo echo "=== \$DC selector wiring (DOCFIX-166; tooling gap register #15 sub-item 2) ===" -run 0 "DC=dc0 explicit: identical no-drift six-plane done" done dc0 +run 0 "DC=vr0-dc0 explicit: identical no-drift six-plane done" done vr0-dc0 has 'no drift' has 'OK \(dryrun\) -- topology consistent with D-052/D-053' -run 0 "DC=dc1: net selector no-ops (D-101), same hardcoded PLANES table" done dc1 +run 0 "DC=vr1-dc0: net selector no-ops (D-101 inheritance), same hardcoded PLANES table" done vr1-dc0 has 'no drift' has 'OK \(dryrun\) -- topology consistent with D-052/D-053' -run_dc 1 "DC=dc2: net selector fails loud (NetBox gap)" dc2 +run_dc 1 "DC=vr1-dc1: net selector fails loud (NetBox gap)" vr1-dc1 has 'no assigned network literals yet' absent 'DO:|WOULD:' # must exit before any MAAS interaction diff --git a/tests/reenroll-hosts/run-tests.sh b/tests/reenroll-hosts/run-tests.sh index 0ed29f7..f37f9ee 100644 --- a/tests/reenroll-hosts/run-tests.sh +++ b/tests/reenroll-hosts/run-tests.sh @@ -50,18 +50,26 @@ has 'NOT-ENROLLED' has 'read-only check mode; no changes made' -run 0 "DC=dc0 explicit: identical --check output" dc0 +run 0 "DC=vr0-dc0 explicit: identical --check output" vr0-dc0 has 'NOT-ENROLLED' has 'read-only check mode; no changes made' -run_dc 1 "DC=dc1: hosts selector fails loud (no per-DC host data)" dc1 +run_dc 1 "DC=vr1-dc0: hosts selector fails loud (no per-DC host data)" vr1-dc0 has 'no enrolled hosts yet' absent 'Current host status' # must exit before report() ever runs -run_dc 1 "DC=dc2: net selector fails loud first (NetBox gap)" dc2 +run_dc 1 "DC=vr1-dc1: net selector fails loud first (NetBox gap)" vr1-dc1 has 'no assigned network literals yet' absent 'Current host status' +# D-119 REGRESSION GUARD: the bare dcN tokens are RETIRED. 'dc0' used to mean +# VR0's LIVE cloud here but VR1's FIRST DC in the NetBox importer -- one string, +# two clouds. Accepting it silently is how the off-by-one comes back. +for _bare in dc0 dc1 dc2; do + run_dc 1 "DC=$_bare: RETIRED bare selector is REJECTED (D-119)" "$_bare" + has 'RETIRED' +done + run_dc 1 "DC=bogus: unknown token fails loud" bogus has "unknown DC 'bogus'"