diff --git a/docs/dc-dc-netbox-buildout-scope.md b/docs/dc-dc-netbox-buildout-scope.md index d3ca841..d766147 100644 --- a/docs/dc-dc-netbox-buildout-scope.md +++ b/docs/dc-dc-netbox-buildout-scope.md @@ -55,7 +55,7 @@ | G2 | **Aggregates** | **0 aggregates** despite ARIN RIR present | Create aggregates (section 4b) -- IPAM apex should declare its allocations | | G3 | **Org ULA `/48`** | Not present (only `fc00::/8`, `fd00::/8` containers) | Assign + create (section 4c); the D-101 literal | | G4 | **VR1 VLAN groups** | VR1 DC0/DC1 have **no VLAN groups** (VR0 DC0 + prod sites do) | Create per-DC VLAN groups + VLANs | -| G5 | **DC naming** | NetBox = `vr1-dc0`/`vr1-dc1`; decisions + import tool = **DC1/DC2** (`--dc dc1` targets `vr1-dc1`, DC2 -> `vr1-dc2` which does not exist) | Operator IPAM-hygiene call: rename to `vr1-dc1`/`vr1-dc2`, or re-point the tool. **Blocks the import** until resolved | +| G5 | **DC naming** | ~~NetBox = `vr1-dc0`/`vr1-dc1`; tool = DC1/DC2~~ | **CLOSED by D-117 (ADOPTED 2026-07-13).** The REPO moved to the apex's 0-indexed names: `--dc dc0` -> `vr1-dc0`, `--dc dc1` -> `vr1-dc1`. The recommendation BELOW (rename the apex to `vr1-dc1`/`vr1-dc2`) was **REJECTED** -- the apex is not bent to fit the repo. The office KEEPS its number (`vr1-off1`). | | G6 | **VR0 DC0 / Willamette prefixes** | Fully built on the **OLD** model: single roles (`provider`/`metal`/`data`/`storage`/`repl`/`lbaas-mgmt`), **all GUA**, no ULA | This is the v1/DC0-era model, **not** the VR1 template. Leave as-is or retire (hygiene call, D-101 site-collision note) | --- @@ -113,9 +113,9 @@ literals. Recommended literals (pending G5 naming + section 5 ratification): ``` # DC1 (v4 inherited; only v6 top-level blocks required) -ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f02::/48 --dc dc1 +ORG_ULA_48= DC_GUA_PREFIX=2602:f3e2:f02::/48 --dc dc0 # D-117: VR1 DC0 # DC2 (distinct v4 supernet ALSO required -- >= /19; tool re-packs 6 sequential /22s, see 5.3) -ORG_ULA_48=fd50:840e:74e2::/48 DC_GUA_PREFIX=2602:f3e2:f03::/48 DC2_V4_SUPERNET=10.12.64.0/19 --dc dc2 +ORG_ULA_48= DC_GUA_PREFIX=2602:f3e2:f03::/48 DC1_V4_SUPERNET=10.12.64.0/19 --dc dc1 # D-117: VR1 DC1 ``` Run `--verify-only` first. The tool creates the six-plane v4 + the D-101 family-matrix v6 legs. @@ -147,9 +147,10 @@ offsets, `/60`+`/64` per plane, metal `/60` shared by admin `:20` / internal `:21`, ULA `/56` indexed to the GUA nibble so the 4th hextet reads `DC.NN` in both families. -2. **DC naming (G5).** `vr1-dc0`/`vr1-dc1` (NetBox) vs DC1/DC2 (decisions/tool). **Recommend:** - rename sites to `vr1-dc1`/`vr1-dc2` to match the adopted DC1/DC2 language and the tool's - `--dc dc2 -> vr1-dc2` expectation; retire or repurpose the now-orphaned `vr1-dc0`. +2. ~~**DC naming (G5).**~~ **SUPERSEDED -- do NOT follow this.** This recommended renaming the APEX + to `vr1-dc1`/`vr1-dc2`. **D-117 (ADOPTED 2026-07-13) ruled the opposite:** the repo moves to the + apex's names (`dc0`/`dc1`), because NetBox is the IPAM apex and is not bent to fit the repo. The + import tool now REJECTS `--dc dc2` and the `DC2_V4_SUPERNET` env var by name. 3. **DC2 v4 supernet value + size.** D-101 leaves it NetBox-assigned. The import tool (`DC2_MIN_SUPERNET_PREFIXLEN = 19`) carves **six SEQUENTIAL `/22`s** from the supernet base and @@ -183,6 +184,7 @@ | Literal | Status | Recommendation | |---|---|---| | `ORG_ULA_48` | unassigned | `fd50:840e:74e2::/48` (ratify) | -| `DC_GUA_PREFIX` dc1 | ambiguous | `2602:f3e2:f02::/48` (existing VR1 DC0 site block, renamed dc1) | -| `DC_GUA_PREFIX` dc2 | ambiguous | `2602:f3e2:f03::/48` (existing VR1 DC1 site block, renamed dc2) | +| `ORG_ULA_48` | **UNASSIGNED -- BLOCKS the DC import** | `fd50:840e:74e2::/48` recommended below, but **NOT RATIFIED**. No ADOPTED decision assigns it. Needs its own D-number before `dc-dc-prefixes-import.py` can run. | +| `DC_GUA_PREFIX` **dc0** | **MEASURED** (D-117) | `2602:f3e2:f02::/48` -- the apex's `vr1-dc0` block. Not renamed; the repo adopted the apex's name. | +| `DC_GUA_PREFIX` **dc1** | **MEASURED** (D-117) | `2602:f3e2:f03::/48` -- the apex's `vr1-dc1` block. | | `DC2_V4_SUPERNET` | **ASSIGNED (D-115)** | **`10.12.64.0/19`** -- INSIDE the Cloud `/16`, free (DC1 holds `10.12.{4,8,12,16,32,36}`), DC1<->DC2 stay routable. The old `10.13.0.0/19` recommendation was OUTSIDE every allocated block (it would have squatted); D-115 supersedes it. Tool needs >= `/19` and re-packs 6 sequential `/22`s -- see 5.3. | diff --git a/docs/session-ledger.md b/docs/session-ledger.md index f61e58f..dd9d251 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -281,7 +281,19 @@ (`netbox/prod-draft-dump.py` READ-ONLY -> `netbox/draft/vr1-draft.json` -> `netbox/sandbox-seed.py`), seeded 129 objects from the upstream draft, then applied the **D-115 office carve** (`netbox/d115-office-carve.py`): site `vr1-off1`, roles `office` + **`edge`** (new), and 8 prefixes. -Idempotent, verified. See `docs/vr1-office1-as-built.md` section 6. +Idempotent, and **verified FIELD-BY-FIELD** against the upstream draft +(`netbox/sandbox-fidelity-check.py`): every shared object is IDENTICAL, and the delta is EXACTLY +`+edge` role, `+vr1-off1` site, `+8` D-115 prefixes. The sandbox is a proven-faithful replica, which +is what makes it a valid baseline for the later feed-back diff. See `docs/vr1-office1-as-built.md` s6. + +**Why that check exists:** counts and idempotency CANNOT prove fidelity. The seeder silently dropped +the scope on 17 of 90 prefixes (it mapped only `dcim.site`; most of the VR1 draft is +`dcim.region`-scoped) -- and every count still matched and re-runs were still idempotent. It was +caught by LUCK on a spot-check. The fidelity check is that catch, made deliberate. + +**D-117 Option B is only PARTIALLY EXECUTED.** Done: the import path (`dc-dc-prefixes-import.py`) and +`docs/dc-dc-netbox-buildout-scope.md`. NOT done: the `$DC` shell selector (below) and the +forward-looking DC prose in the runbooks. Do not read "D-117 ADOPTED" as "fully renamed". **D-117 ADOPTED (operator ruling, Option B, DC-only):** VR1's DCs are **dc0/dc1**, matching the apex. The **office KEEPS its number** (Off1). Fixed the importer's off-by-one site binding and made it diff --git a/netbox/sandbox-fidelity-check.py b/netbox/sandbox-fidelity-check.py new file mode 100755 index 0000000..a62bfd1 --- /dev/null +++ b/netbox/sandbox-fidelity-check.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Prove a seeded sandbox is a FAITHFUL replica of the upstream draft, plus an +expected delta. Read-only; compares two JSON dumps, touches no NetBox. + +WHY THIS EXISTS. Counts and idempotency CANNOT prove fidelity. On 2026-07-13 the +seeder silently dropped the scope on 17 of 90 prefixes (it mapped only +`dcim.site`, and most of the VR1 draft is `dcim.region`-scoped) -- and every +count still matched, and re-running was still perfectly idempotent. It was caught +by luck on a spot-check that happened to print `site None`. This script is the +check that would have caught it on purpose. + +It diffs FIELD BY FIELD on every shared object, so a dropped or mangled field is +a hard failure rather than an invisible one. + +Usage: + # dump upstream (on vcloud), and the sandbox (on office1-netbox) -- SAME + # read-only dumper, pointed at each in turn: + python3 netbox/prod-draft-dump.py --out netbox/draft/vr1-draft.json + python3 netbox/prod-draft-dump.py --out /tmp/sandbox-dump.json # NETBOX_URL=sandbox + + python3 netbox/sandbox-fidelity-check.py \ + --upstream netbox/draft/vr1-draft.json --sandbox /tmp/sandbox-dump.json + +Exit 0 = faithful replica + exactly the expected delta. Exit 1 = the seeder is +dropping or mangling data. +""" + +import argparse +import json +import sys + +ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) +ap.add_argument("--upstream", required=True, help="JSON dump of the upstream draft") +ap.add_argument("--sandbox", required=True, help="JSON dump of the seeded sandbox") +args = ap.parse_args() + +up = json.load(open(args.upstream)) +sb = json.load(open(args.sandbox)) + +KEY={"ipam/rirs":"slug","ipam/roles":"slug","dcim/regions":"slug","dcim/sites":"slug", + "ipam/aggregates":"prefix","ipam/prefixes":"prefix"} +EXPECTED_NEW={"ipam/roles":{"edge"},"dcim/sites":{"vr1-off1"}, + "ipam/prefixes":{"10.10.0.0/16","10.10.0.0/22","10.10.0.0/24","10.10.1.0/24", + "172.30.0.0/16","172.30.1.0/24", + "2602:f3e2:f01:100::/56","2602:f3e2:f01:100::/64"}} +bad=0 +for ep,k in KEY.items(): + U={r[k]:r for r in up.get(ep,[])} + S={r[k]:r for r in sb.get(ep,[])} + missing=set(U)-set(S) + extra=set(S)-set(U) + exp=EXPECTED_NEW.get(ep,set()) + print(f"\n{ep}: upstream={len(U)} sandbox={len(S)}") + if missing: + bad+=1; print(f" !! MISSING FROM SANDBOX ({len(missing)}): {sorted(missing)[:6]}") + unexpected=extra-exp + if unexpected: + bad+=1; print(f" !! UNEXPECTED EXTRA ({len(unexpected)}): {sorted(unexpected)[:6]}") + elif extra: + print(f" + expected D-115 additions ({len(extra)}): {sorted(extra)}") + # FIELD-LEVEL fidelity on the shared keys -- this is the check that counts cannot make + drift=[] + for key in sorted(set(U)&set(S)): + u,s=U[key],S[key] + for f in set(u)|set(s): + if f.startswith("_"): continue + if u.get(f)!=s.get(f): + drift.append((key,f,u.get(f),s.get(f))) + if drift: + bad+=1 + print(f" !! FIELD DRIFT on {len(set(d[0] for d in drift))} object(s), {len(drift)} field(s):") + for key,f,uv,sv in drift[:12]: + print(f" {key:<26} {f:<14} upstream={uv!r} sandbox={sv!r}") + else: + print(" field-level: IDENTICAL on every shared object") +print("\n" + "=" * 70) +if bad == 0: + print("SANDBOX IS A FAITHFUL REPLICA + the expected D-115 delta") + sys.exit(0) +print(f"!! {bad} fidelity problem(s) -- the seeder is dropping or mangling data") +sys.exit(1) + diff --git a/tests/sandbox-seed/run-tests.sh b/tests/sandbox-seed/run-tests.sh index 58beb48..2c1817b 100755 --- a/tests/sandbox-seed/run-tests.sh +++ b/tests/sandbox-seed/run-tests.sh @@ -72,6 +72,15 @@ grep -q 'dcim.region' "$DUMP" && ok || bad "prod-draft-dump no longer carries REGION-scoped prefixes (17 of 90 upstream)" grep -q 'dcim.region' "$SEED" && ok || bad "sandbox-seed no longer resolves REGION scopes" +# 8. the fidelity checker must exist and must be READ-ONLY. Counts and +# idempotency cannot prove a faithful seed -- the region-scope drop proved that +# (17 of 90 prefixes lost their scope; every count still matched). +FID="$NBDIR/sandbox-fidelity-check.py" +[ -f "$FID" ] && ok || bad "netbox/sandbox-fidelity-check.py is gone -- nothing proves the seed is faithful" +python3 -c "import ast;ast.parse(open('$FID').read())" 2>/dev/null && ok || bad "sandbox-fidelity-check.py does not parse" +grep -qE '"(POST|PATCH|PUT|DELETE)"' "$FID" && bad "sandbox-fidelity-check.py can WRITE -- it must be read-only" || ok +grep -q 'FIELD BY FIELD' "$FID" && ok || bad "the fidelity check no longer compares field-by-field -- counts alone hid the last bug" + echo total=$((pass+fail)) if [ "$fail" -eq 0 ]; then echo "sandbox-seed: $pass/$total PASS"; exit 0; fi