Newer
Older
openstack-caracal-dc-dc / docs / dc-dc-netbox-buildout-scope.md

DC-DC (VR1) NetBox Buildout -- Planning & Decision Scope

Status: planning/scope reference for the Code stream. NOT a decision record -- it consolidates ADOPTED decisions and flags the sub-decisions still needing operator ratification (candidate D-111+; assign via ledger-scan at adoption). Authority order: repo design-decisions.md (ADOPTED) > this doc > live NetBox (a reconciliation target, not a source) > design-exploration chats. NetBox is the IPAM apex (D-010, D-103) -- everything below it consumes it; it is never read back as a planning input. Scope: what must exist in netbox.baldurkeep.com before Phase 1 completes so the VR1 two-DC buildout can consume it. Reconciled against a live read of NetBox on 2026-07-10 (v4.5.8).


1. Authoritative address/family model (D-101, D-100, D-108)

Six planes per DC, family follows reachability. Verbatim from D-101 / dc-dc-buildout-design.md:

Plane v4 (DC1 = DC0 template) v6 family Notes
provider-public 10.12.4.0/22 GUA (dual) public API VIPs + FIP/ext_net; GUA via owned OPNsense edge (D-015 gate N/A in VR1)
metal-admin 10.12.8.0/22 ULA (dual) admin-API ULA leg (2026-07-09 amendment); PXE stays v4-first
metal-internal 10.12.12.0/22 ULA (dual) API endpoints dual-stack; MySQL/RabbitMQ stay v4
data-tenant 10.12.16.0/22 ULA-only geneve overlay; tenant GUA delegated separately
storage 10.12.32.0/22 ULA-only Ceph public; ms_bind_ipv6
replication 10.12.36.0/22 ULA-only Ceph cluster; the cross-DC leg rides this ULA plane (D-108)

Also ULA-only: Octavia lb-mgmt and P2P links. IPv4-retained: MAAS PXE (v4-first) and datastore east-west (MySQL/RabbitMQ). Tenant GUA: native routing, no NAT66, SLAAC default, /60 per tenant (~4080/pool; /56 on request) -- delegated from a per-DC tenant GUA pool, distinct from the infra planes.

Per-DC v4 (D-101): DC1 inherits DC0's six-plane CIDRs unchanged (the six above). DC2 gets a distinct, non-overlapping parallel supernet, same six-plane structure, non-colliding with DC1 + operator infra + client VPN (D-074). Cross-DC replication carrier = IPv6-only ULA /64 (D-101/D-108; supersedes the DR-seed 10.12.36.0/22 v4 line).

**Three literals are NetBox-assigned, NOT hardcoded in any decision (D-101 open item / tooling-gap

#3):** the org ULA /48, the per-DC GUA carve out of the /36, and DC2's v4 supernet. The import tool takes them as required env vars with no defaults.


2. Live NetBox state (2026-07-10) -- reconciliation

netbox.baldurkeep.com v4.5.8. Regions: US West -> Eugene (prod); Virtual Regions -> VR0, VR1. Sites incl. VR1 DC0 (vr1-dc0), VR1 DC1 (vr1-dc1). 90 prefixes (77 v6 / 13 v4), 56 VLANs, 2 VRFs (Roosevelt, Willamette), 1 RIR (ARIN).

What is already right, and what is missing/divergent -- Code must close these before the import runs:

# Finding State Action
G1 Six-plane IPAM roles Only storage exists; provider-public, metal-admin, metal-internal, data-tenant, replication MISSING Create the 5 roles out-of-band (import script does NOT create roles)
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; 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)

3. Structure to build on (RECONCILED template)

The deployed carve and D-101 agree on a byte-aligned layout; use it for VR1. Template 2602:f3e2:0RSS:CCNN where nibble-1 = 0 (region-0 = the held /36; varies once expanded to the /32), nibble-2 = the region/metro selector (Eugene=1, VR0=e, VR1=f), SS = site byte (/48), CC = cloud byte (/56), NN = net byte (/64). Net-byte role mnemonic already in use: 10 provider, 11 provider-VIP, 20 metal, 30 data, 40 storage, 50 replication, 80 lbaas, e0 VPN, f0 OOB.

VR1 already occupies 2602:f3e2:f00::/40 with DC0=f02::/48, DC1=f03::/48 as sites. The per-DC GUA carve (D-101 literal) should be these existing /48 site blocks (DC_GUA_PREFIX = 2602:f3e2:f02::/48 for DC1, 2602:f3e2:f03::/48 for DC2), aligned to G5's naming decision -- not the README's placeholder 2602:f3e2:1000::/40.

RECONCILIATION NOTE (design-chat vs repo -- for the operator, so nothing surprises you): The parallel IPAM-design chat produced a carve (v3 / dual-region bundle) that diverged from what is deployed and adopted. Those artifacts are superseded for the DC-DC buildout; the deployed pattern + D-101 govern. Specifically:

  • Region label: chat used RMSS (region=/36, a "metro" at /40); deployed + D-101 use region-0=/36 with the /40 as the region/metro selector (0RSS). Addresses match for production; only VR0/VR1 placement differed -- the chat bundle put them at nibble-1 (2602:f3e2:e000::/36, f000::/36); NetBox has them at nibble-2 (0e00/0f00::/40). Deployed nibble-2 placement wins.
  • Tenant size: chat concluded /64-per-tenant (assuming tenants share the infra cloud /56); D-101 says /60, because tenant GUA is a separate delegation pool, not the infra /56. D-101 /60 wins.
  • Roles: chat used the old 5-6 role set; VR1 uses the six-plane set. Six-plane wins.

4. Buildout steps for Code (ordered; all idempotent, dry-run first)

4a. IPAM roles (G1). Create the 5 missing six-plane roles (provider-public, metal-admin, metal-internal, data-tenant, replication; storage exists). Slugs must match scripts/lib-net.sh SPACES6 exactly -- the import tool references them by slug. Created by netbox/roles-aggregates-import.py (this delivery).

4b. Aggregates (G2). Under RIR ARIN: 2602:f3e2::/36 (the held allocation = region-0 of the planned /32; update to /32 post-expand -- declaring a /32 you don't hold is not honest for an IPAM apex), 23.157.124.0/24. Under a new RFC-4193 RIR: the org ULA /48 (4c). Under a new RFC-1918 RIR: 10.0.0.0/8, 172.16.0.0/12. All created idempotently by netbox/roles-aggregates-import.py (this delivery).

4c. Org ULA /48 (G3, D-101 literal). Assign one RFC-4193 pseudo-random /48. Recommended value: fd50:840e:74e2::/48 -- CSPRNG-generated (Global ID 0x50840e74e2), collision-checked against the Tailscale fd7a:115c:a1e0::/48 already in NetBox (distinct). This is a recommendation for the operator to ratify as the literal, not an invented fact; if adopted, it becomes ORG_ULA_48 for the import tool.

4d. Per-DC prefixes via netbox/dc-dc-prefixes-import.py (D-101). Run per DC with the three 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 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  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.

4e. VLAN groups + VLANs (G4). Per-DC VLAN groups scoped to vr1-dc1/vr1-dc2, VLANs per the plane->VID scheme (mirror docs/maas-as-built-reference.md; note metal-internal VID 103 per the buildout doc). VIDs reuse per DC (separate fabrics).

4f. Tenant GUA delegation pool. Per D-101, a per-DC tenant GUA pool (from the DC's GUA carve or a dedicated block) from which /60s are delegated (SLAAC, native-routed). Model the pool; per- tenant /60s are Neutron-managed (D-016/D-074), not enumerated in NetBox.


5. Sub-decisions requiring operator ratification (candidate D-111+)

None of these block reading NetBox; they block trusting the exact values written. Each is a grep-before-assign D-NNN when ruled.

  1. Per-plane subcarve (the big one). dc-dc-prefixes-import.py's ULA/GUA subdivision is the script's OWN proposal, explicitly un-ratified: ULA = a /56-per-DC out of the org /48 (dc1 ::0/56, dc2 ::1:00/56), then a /64 per ULA-bearing plane in fixed order; GUA = provider-public gets the whole DC_GUA_PREFIX as one /64. Two conflicts to resolve: (a) this does NOT use the deployed NN role mnemonic (provider at NN=10, etc.) -- decide whether to align the tool to the deployed mnemonic or accept the tool's index scheme; (b) the ULA /56-per-DC index is independent of the GUA geography -- confirm that's acceptable (it matches the "internal /48 reused across the domain" intent). Recommend: align the tool's per-plane /64 offsets to the NN mnemonic so GUA and ULA read consistently, then ratify. RATIFIED as D-111 (2026-07-11) + implemented in carve_v6 (DOCFIX-182): NN 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).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 /22s from the supernet base and FAILS LOUD below /19 (a /19 = 8 /22s; six used, two spare). So a /19 is correct (10.12.64.0/19 per D-115; was 10.13.0.0/19); DC2's planes land at sequential offsets (.0/.4/.8/.12/.16/.20), NOT mirroring DC1's .4/.8/.12/.16/.32/.36 -- fine per D-101 (same six-plane structure, not same offsets). Just pick a non-colliding /19 (per D-074).

  4. Tenant size confirmation. D-101 says /60. Confirm the design-chat /64 is dropped (it is, per section 3) so Code builds the /60 delegation model.

  5. VR0 DC0 old-model retirement. Keep for history vs retire once VR1 supersedes the rehearsal (D-101 site-collision note leaves this to the operator).


6. Cross-references

  • Decisions: D-100 (fabric/topology), D-101 (address plan + family matrix; folds D-102 tenant/MTU), D-103 (IaC seam; NetBox apex), D-104 (controllers), D-106 (Designate A/AAAA), D-107 (per-DC mirror), D-108 (replication carrier = ULA), D-109 (Vault v6-SAN). D-010 (NetBox-upstream), D-052/D-053 (six-plane), D-016/D-074 (tenant CIDR).
  • Tooling: netbox/dc-dc-prefixes-import.py (+ README.md), netbox/ipv6-mark-reserved.py, scripts/lib-net.sh (SPACES6/PLANE_CIDRS), docs/dc-dc-buildout-design.md (Phase 1 gate), docs/maas-as-built-reference.md (VLANs/fabrics), docs/dc-dc-replication-DR-seed.md.
  • Phase: this is the Phase 1 NetBox-population gate ("NetBox authoritative and populated: planes, per-DC v4, ULA/GUA") in dc-dc-buildout-design.md.

7. Open literals summary (hand to whoever assigns them)

Literal Status Recommendation
ORG_ULA_48 unassigned fd50:840e:74e2::/48 (ratify)
ORG_ULA_48 ASSIGNED (D-118) fd50:840e:74e2::/48 -- RFC 4193 valid, no collision with Tailscalesfd7a:115c:a1e0::/48`. Unblocks the DC import.
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 /22s -- see 5.3.

8. The NetBox sandbox loop (C4)

8.1 Why a second NetBox exists

DOCFIX-195 (operator ruling 2026-07-15) -- READ THIS FIRST; it INVERTS the roles the rest of this section was originally written around. During VR1 the VR1 IPAM APEX is office1-netbox (10.10.1.10:8000, NetBox 4.6.4 on the Office1 VM, docs/changelog-20260713-office1-netbox-deployed.md): it takes ALL VR1 writes (the section-4 buildout, the D-120 additions, any further refinement) and every consuming system (OpenTofu, MAAS, overlays, the Juju bundle) derives its NetBox values from it, not from netbox.baldurkeep.com. It is a working draft -- edited as the buildout proceeds -- but it is the authoritative NetBox. netbox.baldurkeep.com is held as a READ-ONLY v1 REFERENCE DRAFT for the duration -- it gets NO write during VR1. Merging the validated office1-netbox dataset back into netbox.baldurkeep.com is a DEFERRED, end-of-deployment MAYBE (8.6), not a Stage 2 gate. This DEFERS the apex write; it does NOT revoke D-010 -- netbox.baldurkeep.com remains the eventual IPAM source of truth.

The loop that builds and validates office1-netbox from the draft is unchanged; only the FINAL step (feeding the apex) moved OUT of Stage 2:

upstream draft (READ-ONLY, v1 reference)  --[prod-draft-dump.py]-->  netbox/draft/vr1-draft.json
vr1-draft.json                            --[sandbox-seed.py]------>  office1-netbox (the RECORD)
...apply the planned delta in office1-netbox (the section-4 importers + the D-120 additions)...
...prove office1-netbox == draft + EXACTLY the planned delta (fidelity check)...
validated dataset                         --[DEFERRED, end-of-deployment, operator-gated]-> apex

The importers NEVER --commit the apex as a side effect (8.4). Writing office1-netbox is normal and unguarded (it is in SANDBOX_HOSTS); writing netbox.baldurkeep.com stays behind --yes-write-upstream and is out of scope until the deferred merge-back.

8.2 Why a FIDELITY CHECK, and not just counts

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 stayed idempotent. It was caught by luck on a spot-check printing site None. netbox/sandbox-fidelity-check.py is the check that catches it on purpose: it diffs FIELD BY FIELD on every shared object, so a dropped or mangled field is a hard failure.

8.3 The 2026-07-14 hardening -- do NOT trust a pre-hardening "faithful" verdict

Until 2026-07-14 the fidelity check could return a FALSE GREEN. It only proved the sandbox delta was a SUBSET of the plan (unexpected = extra - expected); it NEVER proved the plan was CARRIED OUT. With ZERO of the 36 DC prefixes created, extra and unexpected were both empty, and it printed "Nothing lost, nothing stray" and exited 0. EXPECTED_NEW was an upper bound masquerading as an assertion. Fixed 2026-07-14 (docs/changelog-20260714-netbox-write-path-hardening.md): EXPECTED_NEW is now BOTH bounds (EXPECTED-BUT-ABSENT is a hard failure), and delta prefixes are checked for scope and role. A NEW harness tests/sandbox-fidelity-check/run-tests.sh (10/10; T3 IS the false-green scenario) ships with it -- the checker had shipped with NO harness, which is why the false green survived.

CONSEQUENCE: any "sandbox is faithful" claim dated before 2026-07-14 was made by a checker that could not see an unapplied or under-applied plan. Re-run the current checker before trusting fidelity (this is why the ledger flags a sandbox re-verify owed before C2).

Two blind spots remain OPEN (logged, not fixed): SAME-DUMPER (both sides use the same field list, so a field the dumper omits is invisible on both), and DUPLICATE-CIDR collapse (prefix-keyed dicts collapse duplicates; latent -- none upstream today). "Faithful" means faithful within the dumped field list, not absolutely.

8.4 Write-guard architecture -- FIVE tools, and how each is kept off the apex

"The sim never writes upstream" is enforced differently per tool. As of 2026-07-14 the three write-capable importers ALL gate an upstream --commit, but by two different mechanisms:

Tool Default Upstream write-guard
prod-draft-dump.py read-only; no --commit N/A -- performs no writes
sandbox-seed.py DRY; --commit writes STRUCTURAL: holds no upstream URL/token and dies if pointed at the draft's own source host -- it CANNOT reach upstream
d115-office-carve.py DRY; --commit writes SANDBOX_HOSTS allowlist {localhost,127.0.0.1,10.10.1.10}; a non-sandbox --commit REFUSES without --yes-write-upstream
roles-aggregates-import.py PREVIEW; --commit writes SANDBOX_HOSTS + --yes-write-upstream (added 2026-07-14) -- plus a whole-plan PREFLIGHT so it cannot half-write
dc-dc-prefixes-import.py DRY; --commit writes SANDBOX_HOSTS + --yes-write-upstream (added 2026-07-14) -- plus a whole-plan role PREFLIGHT so it cannot half-write

Before 2026-07-14 the two pynetbox importers had NO target guard at all; the only thing stopping an accidental upstream --commit was the WAF 403 (8.5) -- and fixing the WAF (below) removed that accidental safety, which is exactly why the explicit --yes-write-upstream gate was added in the same change (docs/changelog-20260714-netbox-importers-waf-useragent.md). The whole-plan preflights are the OTHER layer: both importers had previously died mid-loop and left the production IPAM half-populated (docs/changelog-20260714-netbox-write-path-hardening.md).

8.5 The upstream WAF trap (FIXED 2026-07-14)

Upstream NetBox sits behind a User-Agent-filtering WAF: urllib/requests/pynetbox get a 403 that looks EXACTLY like an auth failure, while the SAME token works from curl (measured 2026-07-13, re-measured 2026-07-14: curl -> 200, urllib/pynetbox -> 403; references/platform-traps.md). Do NOT diagnose it as a token problem and re-mint credentials -- confirm with curl first.

The three stdlib tools always sent an accepted UA (curl/8.5.0). The two pynetbox importers did NOT until 2026-07-14 -- meaning they could NEVER have written to the apex, only to the WAF-less sandbox. get_nb() in both now sets nb.http_session.headers["User-Agent"] = "curl/8.5.0" (docs/changelog-20260714-netbox-importers-waf-useragent.md). NOTE: pynetbox is NOT installed on vcloud -- the two pynetbox importers must run on office1-netbox (pynetbox 7.0.0), which reaches upstream through the edge.

8.6 C2 -- office1-netbox holds the validated dataset (apex write-back DEFERRED)

DOCFIX-195 (operator ruling 2026-07-15) REDEFINED C2. C2 is NO LONGER "feed the carve upstream" -- there is NO netbox.baldurkeep.com write in Stage 2 at all. C2 is met when office1-netbox (the VR1 IPAM apex) holds the COMPLETE validated dataset AND it is VERIFIED. Two sub-tasks remain:

(i) Re-run the HARDENED fidelity check against office1-netbox for the shared carve/roles/DCs -- the "faithful" verdict predates the 2026-07-14 hardening (8.3), so it must be re-established. (ii) Load the D-120 additions into office1-netbox -- 10.10.1.0/24's child ranges (ip-ranges: static .2-.49 / dynamic .100-.200 / node .201-.254) and the two service IP assignments (ip-addresses: office1-netbox 10.10.1.10, office1-tailscale 10.10.1.11) -- and verify them.

TOOLING GAP (logged DOCFIX-195, NOT fixed here). The D-120 objects are ip-ranges/ip-addresses -- object classes the sandbox loop does NOT cover: prod-draft-dump.py's ENDPOINTS and sandbox-fidelity-check.py's KEY/EXPECTED_NEW both stop at rirs/roles/regions/sites/aggregates/ prefixes. So the hardened fidelity check CANNOT see the D-120 load. Verifying sub-task (ii) requires EITHER extending both tools (add the two endpoints + KEY entries + EXPECTED_NEW bounds + delta field checks, each with its harness) OR a recorded manual read-back against office1-netbox. That choice is an open C2-execution sub-question, to be ruled when C2 runs.

The DEFERRED merge-back (end-of-deployment, MAYBE). If/when the operator elects to seed netbox.baldurkeep.com from the validated office1-netbox dataset, it is a production IPAM write and NOT part of the sim: (a) run the two pynetbox importers from office1-netbox (pynetbox + apex reach); (b) each needs --yes-write-upstream on top of --commit for the non-sandbox target; (c) the NetBox write-path bugs (session-ledger "NetBox WRITE-PATH BUGS") gate THAT write, not Stage 2 close; (d) present and individually approve each mutation -- never batched. As of 2026-07-15 the apex is untouched and still holds only the v1 draft (measured read-only).