diff --git a/docs/changelog-20260711-d111-subcarve-nn-alignment.md b/docs/changelog-20260711-d111-subcarve-nn-alignment.md new file mode 100644 index 0000000..dbdc52a --- /dev/null +++ b/docs/changelog-20260711-d111-subcarve-nn-alignment.md @@ -0,0 +1,48 @@ +# Changelog 2026-07-11 -- DOCFIX-182: D-111 v6 subcarve aligned to deployed NN mnemonic + +**Context.** The Chat/planning stream handed the Code stream the VR1 NetBox buildout +(`docs/dc-dc-netbox-buildout-scope.md`). A read-only export of the live +`netbox.baldurkeep.com` (2026-07-11) confirmed the deployed VR0-DC0/Willamette clouds +use a net-byte (`NN`) mnemonic (provider `:10`+VIP `:11`, metal `:20`, data `:30`, +storage `:40`, repl `:50`) with `/60`-per-plane + `/64`-active. `dc-dc-prefixes-import.py` +did NOT match it (arbitrary contiguous plane indices, `/64`-only) -- scope-doc +sub-decision #1. Operator deferred to the Code stream's recommendation; ratified as +**D-111**. This implements it. + +## What changed + +### `netbox/dc-dc-prefixes-import.py` +- **Replaced `carve_ula` + `carve_gua` with `carve_v6(dc, org_ula_48, dc_gua_prefix)`** + returning `[(cidr, role_slug, kind)]` on the D-111 layout: provider-public GUA + `/60`+`/64`@`:10` + VIP `/64`@`:11`; metal-admin/metal-internal SHARE the metal `/60`@`:20` + (`:20`/`:21`); data `:30`, storage `:40`, replication `:50` each `/60`+`/64` ULA. +- **New `_sub_at()` helper** -- computes the Nth subnet by direct integer arithmetic, + NEVER `list(base.subnets())` enumeration (preserves the DOCFIX-181 no-hang property; + the old `carve_gua` `next()` fix is subsumed). +- **`DC_V6_INDEX`** maps the per-DC ULA `/56` to the GUA site nibble (dc1->`:02xx`, + dc2->`:03xx`) so the 4th hextet reads `DC.NN` in both families. +- `main()` + `verify()` updated to consume the record list; docstring SUBCARVE SCHEME + rewritten. Per-DC prefix count is now 18 (6 v4 + 12 v6), was 12. + +### `tests/dc-dc-prefixes-import/test_logic.py` +- `carve_ula`/`carve_gua` tests replaced with `carve_v6` structure/family/NN-offset/ + non-overlap/no-hang assertions; end-to-end counts 12 -> 18; e2e GUA blocks use the real + `2602:f3e2:f02::/48` / `f03::/48`. **53/53 PASS** (was 40/40). + +### `docs/design-decisions.md` +- **D-111 ADOPTED** (the ruling above), incl. the metal `/60`-sharing resolution and the + explicit out-of-scope note (lbaas-mgmt/vpn/oob mirrored separately; G5 naming is its own D). + +### `docs/dc-dc-netbox-buildout-scope.md` +- Sub-decision #1 marked RATIFIED as D-111. + +## Verification +- `bash tests/dc-dc-prefixes-import/run-tests.sh` -> ALL PASS (53 checks). +- `bash scripts/repo-lint.sh` -> 0 fail. Standalone address math confirmed for dc1/dc2. +- NOT yet run against a real NetBox (the Office1 simulation instance doesn't exist yet; + it will be the first real target, then refinements flow back to `netbox.baldurkeep.com`). + +## Revert +- `git checkout netbox/dc-dc-prefixes-import.py tests/dc-dc-prefixes-import/test_logic.py` + to the pre-DOCFIX-182 revision; remove the D-111 entry from `design-decisions.md` and the + scope-doc ratification note; `git rm` this changelog. diff --git a/docs/changelog-20260711-ula-gen-command-fix.md b/docs/changelog-20260711-ula-gen-command-fix.md new file mode 100644 index 0000000..6d79867 --- /dev/null +++ b/docs/changelog-20260711-ula-gen-command-fix.md @@ -0,0 +1,24 @@ +# Changelog 2026-07-11 -- DOCFIX-183: fix invalid ULA-generation command in the netem/ULA proposal + +**Context.** While assigning the org ULA /48 for the VR1 NetBox buildout, the +generation command in `docs/dc-dc-netem-and-ula-gua-proposal.md` section 2.1 +(DOCFIX-168) was actually run and produced an **invalid IPv6 address** +(`fd42ff:19d97a::/48` -- 6-hex-digit hextets). Root cause: its `sed` split the +10 hex digits of `openssl rand -hex 5` as 4+6 (`\1\2:\3\4\5`) instead of the +2+4+4 an IPv6 `/48` needs (`fdXX:XXXX:XXXX`). + +## Fix +- `docs/dc-dc-netem-and-ula-gua-proposal.md` section 2.1: `sed` corrected to + `s/\(..\)\(....\)\(....\)/\1:\2:\3/` -> `fdXX:XXXX:XXXX::/48`. Verified: the + corrected command emits a valid `/48` within `fc00::/7`. +- Added a correction note pointing to the **ratified** VR1 value + `fd50:840e:74e2::/48` (from `docs/dc-dc-netbox-buildout-scope.md` 4c); the + command is now framed as the reference method, not a VR1 re-generation step. + +## Verification +- Ran the corrected command: `fdc0:4468:d6bf::/48` -> parses, /48, in `fc00::/7`. +- `bash scripts/repo-lint.sh` -> 0 fail. + +## Revert +- `git checkout docs/dc-dc-netem-and-ula-gua-proposal.md` to the pre-DOCFIX-183 + revision; `git rm` this changelog. diff --git a/docs/dc-dc-netbox-buildout-scope.md b/docs/dc-dc-netbox-buildout-scope.md index 8331c9a..fa5107f 100644 --- a/docs/dc-dc-netbox-buildout-scope.md +++ b/docs/dc-dc-netbox-buildout-scope.md @@ -143,6 +143,9 @@ `/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).** `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 diff --git a/docs/dc-dc-netem-and-ula-gua-proposal.md b/docs/dc-dc-netem-and-ula-gua-proposal.md index c5b4e0c..74b2820 100644 --- a/docs/dc-dc-netem-and-ula-gua-proposal.md +++ b/docs/dc-dc-netem-and-ula-gua-proposal.md @@ -69,7 +69,7 @@ actually does under the hood, not a shortcut invented here): ```bash -printf 'fd%s::/48\n' "$(openssl rand -hex 5 | sed 's/\(..\)\(..\)\(..\)\(..\)\(..\)/\1\2:\3\4\5/')" +printf 'fd%s::/48\n' "$(openssl rand -hex 5 | sed 's/\(..\)\(....\)\(....\)/\1:\2:\3/')" ``` Run this ONCE, by the operator, on a real machine -- the output is the @@ -78,6 +78,14 @@ not just as an environment variable -- this is a permanent organizational identifier, not a per-session value. +> **Correction (DOCFIX-183):** the `sed` above was fixed to split the 10 hex +> digits as 2+4+4 hextets (`fdXX:XXXX:XXXX::/48`); the prior 4+6 split produced +> INVALID IPv6 (a 6-hex-digit hextet -- caught when this command was actually +> run 2026-07-11). **The ratified VR1 value is `fd50:840e:74e2::/48`** (CSPRNG, +> collision-checked vs the Tailscale `/48` in NetBox -- see +> `docs/dc-dc-netbox-buildout-scope.md` section 4c); this command is the +> reference method, not a re-generation step for VR1. + ### 2.2 -- Per-DC GUA carve (from ARIN 2602:f3e2::/32 region-0 /36) This is NOT something to generate randomly -- D-101's own text names this diff --git a/docs/design-decisions.md b/docs/design-decisions.md index b7b868e..8762c14 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -2144,3 +2144,23 @@ **Log-for-sweep (not actioned):** `ledger-scan` DOCFIX / BUNDLEFIX patterns use `[0-9]{3}` -- safe through 999, latent at the 1000 boundary (the same class as the 100-boundary bug already fixed for those series). --- + +## D-111: VR1 per-DC v6 subcarve aligned to the deployed NN mnemonic + +**Status:** ADOPTED 2026-07-11 (operator deferred to Code stream's recommendation; ratifies `docs/dc-dc-netbox-buildout-scope.md` sub-decision #1). + +**Decision:** +- `netbox/dc-dc-prefixes-import.py`'s per-plane v6 subcarve is aligned to the DEPLOYED VR0-DC0 / Willamette net-byte (NN) mnemonic, replacing the original contiguous-plane-index proposal (which was explicitly un-ratified). Each plane gets a `/60` container + `/64` active, mirroring the live template: + - provider-public (GUA, out of `DC_GUA_PREFIX`): `/60` + `/64` active at `:10`, API-VIP `/64` at `:11`. + - metal-admin + metal-internal (ULA) SHARE the metal `/60` at `:20` -- admin `/64` at `:20`, internal `/64` at `:21` -- mirroring how provider `:10` / VIP `:11` share the provider `/60` (the deployed model had a single `metal` plane; the D-052/D-053 six-plane split nests both legs as `/64`s in it). + - data-tenant `:30`, storage `:40`, replication `:50` (ULA): each its own `/60` + `/64`. +- ULA legs are carved from a per-DC `/56` of the org ULA `/48`, indexed to the GUA site nibble (dc1 -> `...:02xx`, dc2 -> `...:03xx`) so the 4th hextet reads `DC.NN` in BOTH families. +- Addresses are computed by direct index arithmetic, NEVER `list(...subnets())` enumeration (preserves the DOCFIX-181 no-hang property). + +**Rationale:** the operator's standing directive is to keep VR1 as close to the real deployment as possible. The live NetBox export (2026-07-11) shows VR0-DC0 / Willamette use exactly this NN mnemonic + `/60`-per-plane structure; aligning the tool means VR1's GUA and ULA both read `:20`=metal, `:30`=data, etc., consistent with the deployed clouds, instead of arbitrary contiguous indices. + +**Out of scope (mirrored separately, not via this six-plane tool):** the `lbaas-mgmt` (`:80`), `vpn` (`:e0`), `oob` (`:f0`) networks from the deployed template. The G5 DC-site rename (`vr1-dc0`/`vr1-dc1` -> `vr1-dc1`/`vr1-dc2`) is a distinct ruling, recorded under its own D-number when executed. + +**References:** `netbox/dc-dc-prefixes-import.py` (`carve_v6`), `docs/dc-dc-netbox-buildout-scope.md` section 5.1, D-101 (family matrix), D-052/D-053 (six-plane), the 2026-07-11 live NetBox export. Harness `tests/dc-dc-prefixes-import/` 53/53. Applies to the Office1 simulation NetBox first; flows back to `netbox.baldurkeep.com` as refined draft. + +--- diff --git a/docs/session-ledger.md b/docs/session-ledger.md index 735ab46..f5cd797 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -1618,6 +1618,47 @@ - **Next: Stage 2** (`dc-dc-phase1-office1-standup`) when directed -- watch gap #17 (per-site ISP-uplink/WAN net; `wan` is now a candidate model). +## Jumphost stream -- session 2026-07-11: VR1 NetBox buildout (Phase 1) -- IPv6 sync + D-111 + +- **SYNCED to the Chat/planning stream.** Merged origin/main (057c31b "netbox + uploads": `docs/dc-dc-netbox-buildout-scope.md` [AUTHORITATIVE], + `netbox/roles-aggregates-import.py`, changelog) into the stage1 branch (clean; + keeps the DOCFIX-181 carve_gua fix). Read the scope doc + tool fully. +- **ARCHITECTURE CLARIFIED (operator):** workflow is draft-source + `netbox.baldurkeep.com` -> spin up a NEW NetBox on the Office1 VM (sandbox) -> + import draft + planned changes -> SIMULATE VR1 against the Office1 NetBox -> + feed validated refinements BACK to `netbox.baldurkeep.com`. The sim NEVER + `--commit`s to the real NetBox (read=source, write=feedback-only). Scope doc + + Stage 2 runbook need a note capturing this loop (pending). +- **Read-only draft export DONE** (`netbox.baldurkeep.com` v4.5.8; session token + stored 0600 at `~/.vr1-netbox.env`, NEVER printed -- REVOKE + purge at session + end). Real modeling: VR1 = `2602:f3e2:f00::/40`, DC0=`f02::/48`, DC1=`f03::/48` + (empty containers). VR0-DC0 (`e02::/48`) is the fully-built TEMPLATE: NN + mnemonic (provider 10/VIP 11, metal 20, data 30, storage 40, repl 50, lbaas 80, + vpn e0, oob f0), `/60`+`/64` per plane, all-GUA (old model). Edge Networks + `2602:f3e2:fe::/48` exists (gap-#17 WAN home; `wan` uses `fe:10`). ULA /48 not + assigned (G3); aggregates empty (G2, top blocks are container PREFIXES). +- **D-111 ADOPTED + IMPLEMENTED (DOCFIX-182):** `dc-dc-prefixes-import.py` v6 + subcarve aligned to the deployed NN mnemonic (`/60`+`/64` per plane; metal `/60` + shared by admin `:20`/internal `:21`; ULA `/56` indexed to GUA nibble so 4th + hextet reads `DC.NN`). `carve_ula`+`carve_gua` -> `carve_v6` via direct-math + `_sub_at` (no enumeration; DOCFIX-181 property kept). Harness 40 -> 53 PASS, + repo-lint 0 fail. +- **FIXED (DOCFIX-183):** `docs/dc-dc-netem-and-ula-gua-proposal.md`'s ULA-gen + `sed` emitted INVALID IPv6 (4+6 split); corrected to 2+4+4. Ratified VR1 ULA is + `fd50:840e:74e2::/48` (from the scope doc), so the command is reference-only. +- **Address values now come from REAL NetBox** (supersede earlier invented + proposals): ORG_ULA_48 recommend `fd50:840e:74e2::/48`, GUA dc1 `f02::/48` / + dc2 `f03::/48`, DC2_V4_SUPERNET `10.13.0.0/19` -- pending final import against + the Office1 NetBox. +- **NEXT:** (a) fix the proposal-doc ULA-gen command; (b) stand up the Office1 + NetBox VM (Stage 2: Office1-local addressing + Option-A cloud-init + NetBox + Docker; `pynetbox`/`python3-venv` NOT installed -- needs operator sudo); + (c) G5 site rename (`vr1-dc0`/`vr1-dc1` -> `vr1-dc1`/`vr1-dc2`) as its own + D-number; (d) run roles-aggregates + prefixes import against the Office1 NetBox. +- **Numbers:** D next-free 112, DOCFIX next-free 184. Branch + `dc-dc-stage1-phase0-first-exec` now also carries the merge + D-111 + DOCFIX-183. + diff --git a/netbox/dc-dc-prefixes-import.py b/netbox/dc-dc-prefixes-import.py index 999aa84..d805a5f 100644 --- a/netbox/dc-dc-prefixes-import.py +++ b/netbox/dc-dc-prefixes-import.py @@ -47,21 +47,23 @@ NetBox. (Precedent: D-102's MTU sub-policy was folded into D-101 only after explicit operator ratification; this carve scheme has not had that pass.) -SUBCARVE SCHEME (proposed, unratified -- review before real use): - - ULA: each DC gets a /56 out of the org /48 (dc1 = ::0/56, dc2 = ::1:00/56, - i.e. the 8 bits immediately after the /48 index the DC -- room for up to - 256 DCs, far beyond VR1's two). Within a DC's /56, each of the three - ULA-family planes (data-tenant, storage, replication) plus the two - dual-stack planes' ULA leg (metal-admin, metal-internal) gets a /64, - indexed in the fixed plane order below (provider-public has no ULA leg, - so its index is skipped -- the /64 offsets are NOT contiguous with the - plane list index, they are assigned only to the five ULA-bearing planes). - - GUA: each DC's already-carved GUA prefix (DC_GUA_PREFIX, e.g. a /40 or - /44 -- length is read from what's actually passed in, not assumed) is - subdivided the same way: provider-public gets the whole thing as a - single /64 (VR1 has no other GUA-bearing plane in the matrix above; - tenant-facing GUA delegation is a separate, later, per-tenant carve out - of this same block -- not this script's job, see netbox/README.md). +SUBCARVE SCHEME (D-111 -- aligned to the DEPLOYED VR0-DC0/Willamette NN +mnemonic; see the D-111 decision record + docs/dc-dc-netbox-buildout-scope.md +section 5.1. Supersedes the original contiguous-plane-index proposal): + - Net-byte (NN) offsets match the live cloud: provider :10 (+ API VIP :11), + metal :20/:21, data :30, storage :40, replication :50. Each plane gets a + /60 container + a /64 active, mirroring the deployed template. + - GUA: provider-public only (D-101 family matrix). Out of DC_GUA_PREFIX (the + per-DC site /48, e.g. 2602:f3e2:f02::/48 for dc1): /60 + /64 active at :10, + API-VIP /64 at :11. + - ULA: the five internal planes, out of a per-DC /56 of the org ULA /48. The + /56 index is the GUA site nibble (dc1 -> ...:02xx, dc2 -> ...:03xx) so the + 4th hextet reads DC.NN in BOTH families. metal-admin (:20) and metal-internal + (:21) SHARE the metal /60 (at :20), exactly as provider (:10) / API-VIP (:11) + share the provider /60. data-tenant :30, storage :40, replication :50 each + get their own /60 + /64. + - Addresses are computed by direct index arithmetic (_sub_at), NEVER + list(...subnets()) enumeration -- the DOCFIX-181 no-hang property is preserved. Idempotent: re-running is safe (existing prefixes are detected and skipped unless --update). NetBox version: 4.x (scope_type/scope_id, not legacy site=). @@ -291,24 +293,44 @@ return {plane: str(subnets[i]) for i, plane in enumerate(PLANE_ORDER)} -def carve_ula(org_ula_48: ipaddress.IPv6Network, dc: str) -> dict: - dc_index = 0 if dc == "dc1" else 1 - # /48 -> per-DC /56 (8-bit DC index). See SUBCARVE SCHEME in module docstring. - dc_56_candidates = list(org_ula_48.subnets(new_prefix=56)) - dc_56 = dc_56_candidates[dc_index] - ula_planes = [p for p in PLANE_ORDER if PLANE_V6_FAMILY[p] in ("ula", "ula-only")] - dc_64_candidates = list(dc_56.subnets(new_prefix=64)) - return {plane: str(dc_64_candidates[i]) for i, plane in enumerate(ula_planes)} +# D-111 alignment: lay the v6 legs out on the DEPLOYED VR0-DC0/Willamette NN +# net-byte mnemonic (provider :10 [+VIP :11], metal :20/:21, data :30, storage +# :40, replication :50), /60-per-plane container + /64 active, mirroring the +# live template. Replaces the earlier contiguous-plane-index subcarve. See the +# D-111 decision record + docs/dc-dc-netbox-buildout-scope.md section 5.1. +# +# Per-DC ULA /56 index aligned to the GUA site nibble (dc1 -> ...:02xx, dc2 -> +# ...:03xx) so the 4th hextet reads DC.NN in BOTH families. +DC_V6_INDEX = {"dc1": 0x02, "dc2": 0x03} -def carve_gua(dc_gua_prefix: ipaddress.IPv6Network) -> dict: - # Only provider-public carries GUA in the current family matrix. Take the - # FIRST /64 LAZILY: a realistic GUA prefix (D-101's own example is a /40) - # has 2**24 = ~16.7M /64 subnets, so list(...subnets(new_prefix=64)) would - # materialize them all and hang/OOM. next() over the generator is O(1). - # (DOCFIX-181 -- found by the harness hanging on the /40 test input.) - first_64 = next(dc_gua_prefix.subnets(new_prefix=64)) - return {"provider-public": str(first_64)} +def _sub_at(base: ipaddress.IPv6Network, new_prefixlen: int, index: int) -> ipaddress.IPv6Network: + """The index-th subnet of `base` at new_prefixlen, computed by direct + arithmetic -- NOT list(base.subnets(...)) enumeration, which would + materialize up to millions of objects and hang (DOCFIX-181).""" + if new_prefixlen < base.prefixlen: + die(f"internal: new_prefixlen /{new_prefixlen} shorter than base {base}") + net_int = int(base.network_address) + index * (1 << (128 - new_prefixlen)) + return ipaddress.ip_network((net_int, new_prefixlen)) + + +def carve_v6(dc: str, org_ula_48: ipaddress.IPv6Network, dc_gua_prefix: ipaddress.IPv6Network) -> list: + """Return [(cidr, role_slug, kind)] for this DC's v6 legs on the deployed NN + layout. provider-public = GUA out of DC_GUA_PREFIX; the five internal planes + = ULA out of a per-DC /56 of the org ULA /48 (D-101 family matrix).""" + ula = _sub_at(org_ula_48, 56, DC_V6_INDEX[dc]) + recs = [ + (str(_sub_at(dc_gua_prefix, 60, 0x10 >> 4)), "provider-public", "GUA /60"), + (str(_sub_at(dc_gua_prefix, 64, 0x10)), "provider-public", "GUA /64 active"), + (str(_sub_at(dc_gua_prefix, 64, 0x11)), "provider-public", "GUA VIP /64"), + (str(_sub_at(ula, 60, 0x20 >> 4)), "metal-admin", "ULA metal /60 (admin+internal)"), + (str(_sub_at(ula, 64, 0x20)), "metal-admin", "ULA /64 active"), + (str(_sub_at(ula, 64, 0x21)), "metal-internal", "ULA /64 active (in metal /60)"), + ] + for plane, nn in (("data-tenant", 0x30), ("storage", 0x40), ("replication", 0x50)): + recs.append((str(_sub_at(ula, 60, nn >> 4)), plane, "ULA /60")) + recs.append((str(_sub_at(ula, 64, nn)), plane, "ULA /64 active")) + return recs # ----------------------------------------------------------------------------- @@ -336,7 +358,7 @@ print(f" EXISTS prefix {cidr} (id={existing.id}) -- skipped (use --update to overwrite)") -def verify(nb, site, v4: dict, v6_ula: dict, v6_gua: dict) -> None: +def verify(nb, site, v4: dict, v6_recs: list) -> None: print() print("=" * 72) print(f"Verification -- final state for site {site.name} (id={site.id})") @@ -347,16 +369,11 @@ p = nb.ipam.prefixes.get(prefix=cidr) status = "MISSING" if p is None else f"OK (id={p.id})" print(f" {plane.ljust(16)} {cidr.ljust(18)} {status}") - print("\nIPv6 ULA (per plane, where applicable):") - for plane, cidr in v6_ula.items(): + print("\nIPv6 (D-101 family matrix / D-111 NN layout):") + for cidr, role_slug, kind in v6_recs: p = nb.ipam.prefixes.get(prefix=cidr) status = "MISSING" if p is None else f"OK (id={p.id})" - print(f" {plane.ljust(16)} {cidr.ljust(24)} {status}") - print("\nIPv6 GUA (per plane, where applicable):") - for plane, cidr in v6_gua.items(): - p = nb.ipam.prefixes.get(prefix=cidr) - status = "MISSING" if p is None else f"OK (id={p.id})" - print(f" {plane.ljust(16)} {cidr.ljust(24)} {status}") + print(f" {role_slug.ljust(16)} {cidr.ljust(26)} {kind.ljust(24)} {status}") def main() -> int: @@ -378,8 +395,7 @@ dc2_supernet = validate_dc2_supernet(require_env("DC2_V4_SUPERNET", "D-101 explicitly requires DC2's v4 supernet be NetBox-assigned, not hardcoded")) v4 = carve_dc2_v4(dc2_supernet) - v6_ula = carve_ula(org_ula_48, dc) - v6_gua = carve_gua(dc_gua_prefix) + v6_recs = carve_v6(dc, org_ula_48, dc_gua_prefix) nb = get_nb() site, site_created = find_or_create_site(nb, site_cfg["slug"], site_cfg["name"]) @@ -403,22 +419,15 @@ nb, v4[plane], plane, site, f"VR1 {dc.upper()} {plane} (v4; D-101)", args.update, ) - print("\nIPv6 ULA prefixes:") - for plane, cidr in v6_ula.items(): + 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, plane, site, - f"VR1 {dc.upper()} {plane} (ULA leg; D-101, proposed subcarve -- see script docstring)", - args.update, - ) - print("\nIPv6 GUA prefixes:") - for plane, cidr in v6_gua.items(): - create_or_report_prefix( - nb, cidr, plane, site, - f"VR1 {dc.upper()} {plane} (GUA leg; D-101, proposed subcarve -- see script docstring)", + nb, cidr, role_slug, site, + f"VR1 {dc.upper()} {role_slug} ({kind}; D-101/D-111)", args.update, ) - verify(nb, site, v4, v6_ula, v6_gua) + verify(nb, site, v4, v6_recs) print("\nDone.") return 0 diff --git a/tests/dc-dc-prefixes-import/test_logic.py b/tests/dc-dc-prefixes-import/test_logic.py index 67f489e..3ca17d6 100644 --- a/tests/dc-dc-prefixes-import/test_logic.py +++ b/tests/dc-dc-prefixes-import/test_logic.py @@ -134,31 +134,52 @@ check(distinct, "carve_dc2_v4 all distinct") # ----------------------------------------------------------------------------- -# carve_ula +# carve_v6 (D-111 NN layout: /60+/64 per plane, deployed net-byte mnemonic) # ----------------------------------------------------------------------------- org_ula = ipaddress.ip_network("fd12:3456:789a::/48") -ula_dc1 = T.carve_ula(org_ula, "dc1") -ula_dc2 = T.carve_ula(org_ula, "dc2") -expected_ula_planes = {p for p in T.PLANE_ORDER if T.PLANE_V6_FAMILY[p] in ("ula", "ula-only")} -check(set(ula_dc1.keys()) == expected_ula_planes, "carve_ula dc1 covers exactly the ULA-bearing planes") -check(set(ula_dc2.keys()) == expected_ula_planes, "carve_ula dc2 covers exactly the ULA-bearing planes") -all_64_dc1 = all(ipaddress.ip_network(c).prefixlen == 64 for c in ula_dc1.values()) -check(all_64_dc1, "carve_ula dc1 all /64") +gua_dc1 = ipaddress.ip_network("2602:f3e2:f02::/48") +recs1 = T.carve_v6("dc1", org_ula, gua_dc1) +recs2 = T.carve_v6("dc2", 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 dc1 yields 12 records", str(len(recs1))) +roles1 = [r for _, r, _ in recs1] +check(roles1.count("provider-public") == 3, "carve_v6 provider-public has 3 (/60,/64,VIP)") +check(roles1.count("metal-admin") == 2, "carve_v6 metal-admin has 2 (/60,/64)") +check(roles1.count("metal-internal") == 1, "carve_v6 metal-internal has 1 (/64 in metal /60)") +for role in ("data-tenant", "storage", "replication"): + check(roles1.count(role) == 2, f"carve_v6 {role} has 2 (/60,/64)") + +# family matrix: provider-public is GUA (within its /48); the rest are ULA (fd..) +addrs1 = {c for c, _, _ in recs1} +prov = [ipaddress.ip_network(c) for c, r, _ in recs1 if r == "provider-public"] +check(all(n.subnet_of(gua_dc1) for n in prov), "carve_v6 provider-public within the GUA /48") +nonprov = [ipaddress.ip_network(c) for c, r, _ in recs1 if r != "provider-public"] +check(all(str(n.network_address).startswith("fd") for n in nonprov), "carve_v6 non-provider planes are ULA") + +# NN offsets land on the deployed mnemonic; DC.NN reads in the 4th hextet +check("2602:f3e2:f02:10::/60" in addrs1, "carve_v6 dc1 provider GUA /60 at :10") +check("2602:f3e2:f02:11::/64" in addrs1, "carve_v6 dc1 provider API-VIP /64 at :11") +check("fd12:3456:789a:220::/64" in addrs1, "carve_v6 dc1 metal-admin ULA /64 at DC.NN :220") +check("fd12:3456:789a:221::/64" in addrs1, "carve_v6 dc1 metal-internal ULA /64 at :221") +check("fd12:3456:789a:230::/64" in addrs1, "carve_v6 dc1 data-tenant ULA /64 at :230") +check("fd12:3456:789a:250::/64" in addrs1, "carve_v6 dc1 replication ULA /64 at :250") +m60 = ipaddress.ip_network("fd12:3456:789a:220::/60") +check(ipaddress.ip_network("fd12:3456:789a:221::/64").subnet_of(m60), "carve_v6 metal-internal /64 sits inside metal /60") + +# dc2 uses the :3NN nibble; the two DCs never overlap +addrs2 = {c for c, _, _ in recs2} +check("fd12:3456:789a:320::/64" in addrs2, "carve_v6 dc2 metal-admin at :320 (DC nibble 3)") +check("2602:f3e2:f03:10::/64" in addrs2, "carve_v6 dc2 provider GUA at f03:10") no_overlap = not any( ipaddress.ip_network(a).overlaps(ipaddress.ip_network(b)) - for a in ula_dc1.values() for b in ula_dc2.values() + for a, _, _ in recs1 for b, _, _ in recs2 ) -check(no_overlap, "carve_ula dc1/dc2 non-overlapping") +check(no_overlap, "carve_v6 dc1/dc2 non-overlapping") -# ----------------------------------------------------------------------------- -# carve_gua -# ----------------------------------------------------------------------------- -if gua is not None: - gua_carved = T.carve_gua(gua) - check(set(gua_carved.keys()) == {"provider-public"}, "carve_gua only provider-public") - g64 = ipaddress.ip_network(gua_carved["provider-public"]) - check(g64.prefixlen == 64, "carve_gua result is /64") - check(g64.subnet_of(gua), "carve_gua result within input prefix") +# 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")) +check(len(recs_big) == 12, "carve_v6 with a /40 GUA completes fast (no subnet enumeration)") # ----------------------------------------------------------------------------- # require_env @@ -196,33 +217,33 @@ fake1 = fake_pynetbox.FakeApi() fake1.preseed_roles(T.PLANE_ORDER) -os.environ["DC_GUA_PREFIX"] = "2602:f3e2:1000::/40" +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f02::/48" with captured_stdout() as out1: rc1 = run_main(["--dc", "dc1"], fake1) check(rc1 == 0, "main dc1 first run rc==0", str(rc1)) created_count = out1.getvalue().count("CREATED prefix") -check(created_count == 12, "main dc1 first run creates 12 prefixes (6 v4 + 5 ula + 1 gua)", str(created_count)) +check(created_count == 18, "main dc1 first run creates 18 prefixes (6 v4 + 12 v6)", str(created_count)) check("vr0-dc0" in out1.getvalue(), "main dc1 prints the vr0-dc0 collision note") with captured_stdout() as out2: rc2 = run_main(["--dc", "dc1"], fake1) check(rc2 == 0, "main dc1 second run (idempotent) rc==0") check(out2.getvalue().count("CREATED prefix") == 0, "main dc1 second run creates nothing new") -check(out2.getvalue().count("EXISTS prefix") == 12, "main dc1 second run reports all 12 as EXISTS") +check(out2.getvalue().count("EXISTS prefix") == 18, "main dc1 second run reports all 18 as EXISTS") # ----------------------------------------------------------------------------- # main() end-to-end -- dc2 happy path (fresh site, distinct v4/GUA) # ----------------------------------------------------------------------------- fake2 = fake_pynetbox.FakeApi() fake2.preseed_roles(T.PLANE_ORDER) -os.environ["DC_GUA_PREFIX"] = "2602:f3e2:2000::/40" +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f03::/48" os.environ["DC2_V4_SUPERNET"] = "10.13.0.0/19" with captured_stdout() as out3: rc3 = run_main(["--dc", "dc2"], fake2) check(rc3 == 0, "main dc2 run rc==0", str(rc3)) -check(out3.getvalue().count("CREATED prefix") == 12, "main dc2 run creates 12 prefixes", str(out3.getvalue().count("CREATED prefix"))) +check(out3.getvalue().count("CREATED prefix") == 18, "main dc2 run creates 18 prefixes", str(out3.getvalue().count("CREATED prefix"))) dc2_site = fake2.dcim.sites.get(slug="vr1-dc2") check(dc2_site is not None, "main dc2 creates the vr1-dc2 site")