diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 287122f..6b418d9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -475,7 +475,7 @@ | G9 | DC0 outer apply (deploy step A) | [V] operator-gated, logged (`run-logged.sh`), after G1-G8; audit exit criteria met (charter Phase 6). SEC pre-apply dependency (S2): SEC-010's transit FORWARD-drop is applied+verified at deploy step B via `site-headend-install.sh --host-nodes --check` on vvr1-dc0 (gate G10) -- the ONLY SEC row gated on this apply (register of record: security-ledger). CANONICAL ENTRY DOC (probe hole H1): `runbooks/dc-dc-phase2-tofu-dc-substrate.md`, with `docs/dc0-deploy-readiness.md` section E as the step table | operator | CLOSED 2026-07-19: G8 same-session planes check passed (6x 0 leases, 0 attachments); saved plan == 6/0/6 applied in the logged dc0-deploy window; convergence re-plan = no differences; vvr1-dc0 running, prior guests untouched | | G10 | Deploy steps B-E in-sequence gates: SEC-010 `--host-nodes --check` on vvr1-dc0; depth-4 nested boot; D-125 foreign-MAC egress test; MAAS reachability + `TF_VAR_maas_api_key` before step D; netem placeholder step E | [V] exercised during the gated deploy | session (each mutation operator-approved) | Step B DONE 2026-07-20 (`--check` EXIT 0 incl. SEC-010, `docs/audit/stepB-check-20260720-final.txt`; interfaces enp1s0/enp2s0). Depth-4 nested boot DONE (10 domains running inside vvr1-dc0). D-125 egress isolation test PASS 2026-07-20 (`docs/audit/d125-egress-gate-20260720-matrix.txt`), and the edge itself now egresses 0% loss after the v4 addressing. Step D COMPLETE incl. commissioning: ALL 9 NODES READY 2026-07-21 (two stacked faults diagnosed + fixed -- `docs/audit/commissioning-diag-20260721.txt`; section 1). Step E (netem) DONE 2026-07-21: sudo fragment installed+verified, module local-mode amendment, targeted apply 1/0/0 exact (operator-ruled at the 1/1/0 STOP), placeholder profile live on virbr5, virbr7/virbr3 untouched (`docs/audit/stepE-netem-20260721.txt` + `outer-{plan,apply}-20260721-netem*.txt`). **G10 CLOSED 2026-07-21** | | G11 | Operator signs THIS document | [R] read top-to-bottom; discrepancies resolved in the document | operator | CLOSED: RE-SIGNED 2026-07-19 at audit exit, section 11 (replaces the 2026-07-18 signature) | -| G12 | `vr1-dc1` build | [R] operator rules dc1 transit/rack addressing; then vars + substrate authored | operator + session | OPEN -- [R] leg CLOSED 2026-07-21: addressing RATIFIED (D-124 amendment 2026-07-21, utterance quoted). Remaining [V] leg: apex confirm-free, vr1_dc1_rack_* vars, dc1 substrate root, build | +| G12 | `vr1-dc1` build | [R] operator rules dc1 transit/rack addressing; then vars + substrate authored | operator + session | OPEN -- [R] leg CLOSED 2026-07-21: addressing RATIFIED (D-124 amendment 2026-07-21, utterance quoted). [V] leg IN PROGRESS (branch `dc-dc-g12-dc1-substrate`): apex confirm-free DONE 2026-07-21 -- planes/uplink already assigned+consistent, transit 172.31.0.4/30 + rack 10.12.68.2 FREE (`docs/audit/dc1-apex-confirm-20260721.txt`); importer per-site dc1 support shipped (harness 117/117) with live dry-run preflight PASS (`docs/audit/dc1-rack-import-dryrun-20260721.txt`). PENDING: operator-gated apex `--commit` (transit /30 + rack IP -- the only owed apex write), then vars + substrate root + build | | G13 | D-129 residuals | [R] operator-gated live plugin install on office1-opnsense; qga channel retrofit at that edge's next scheduled restart. All 4 sub-decisions RULED 2026-07-21 (D-129 Status line) -- only the two execution items remain | operator | OPEN (execution only; decision content complete) | | G14 | 9 OPEN SEC rows (SEC-001, -003..-008, plus SEC-012 + SEC-013 opened 2026-07-20 for credentials this deploy created; SEC-010 CLOSED 2026-07-20, operator-ruled, applied+verified both transit ends) | [R] per-row: rotations/flips at v1 close (external to VR1 track); SEC-012 also carries a SCOPE question (libvirt-group grant is broader than the power verbs MAAS needs), SEC-013 is tied to whether `opentofu/vr1-dc0-maas` is retired | operator / external | `docs/security-ledger.md` (register of record, GA-R4/F3); count re-verified vs `bash scripts/ledger-scan.sh` 2026-07-20 | | G15 | D-068 / D-071 rulings | [R] operator rules (section 8); neither blocks the VR1 substrate | operator | D-071 ADOPTED 2026-07-21 (all four points); D-068 remains PROPOSED/OPEN (items 2-3 + the item-1 re-scoped migration plan) | diff --git a/docs/audit/dc1-apex-confirm-20260721.txt b/docs/audit/dc1-apex-confirm-20260721.txt new file mode 100644 index 0000000..4bf2512 --- /dev/null +++ b/docs/audit/dc1-apex-confirm-20260721.txt @@ -0,0 +1,32 @@ +# dc1 apex confirm-free query -- 2026-07-21T23:45:49Z -- run on office1-netbox (read-only GETs) +== prefixes within_include 10.12.64.0/19 (dc1 supernet) == +count: 6 + 10.12.64.0/22 role=provider-public status=active scope=vr1-dc1 desc=VR1 DC1 provider-public (v4; D-101) + 10.12.68.0/22 role=metal-admin status=active scope=vr1-dc1 desc=VR1 DC1 metal-admin (v4; D-101) + 10.12.72.0/22 role=metal-internal status=active scope=vr1-dc1 desc=VR1 DC1 metal-internal (v4; D-101) + 10.12.76.0/22 role=data-tenant status=active scope=vr1-dc1 desc=VR1 DC1 data-tenant (v4; D-101) + 10.12.80.0/22 role=storage status=active scope=vr1-dc1 desc=VR1 DC1 storage (v4; D-101) + 10.12.84.0/22 role=replication status=active scope=vr1-dc1 desc=VR1 DC1 replication (v4; D-101) +== prefixes within_include 172.31.0.0/24 (transit container) == +count: 2 + 172.31.0.0/24 role=transit status=container desc=Transit (v4) -- dedicated per-DC point-to-point transit supernet (D-12 + 172.31.0.0/30 role=transit status=active scope=vr1-dc0 desc=office1<->dc0 management transit -- region<->rack MAAS control path (D +== prefix exact 172.30.3.0/24 (dc1 uplink) == +count: 1 + 172.30.3.0/24 role=edge status=active scope=vr1-dc1 desc=vr1-dc1 simulated-ISP edge WAN (D-115) +== prefixes within_include 172.30.0.0/16 (edge supernet) == +count: 4 + 172.30.0.0/16 role=edge status=container desc=Edge (v4) -- simulated ISP/WAN segments; mirrors v6 2602:f3e2:fe::/48 + 172.30.1.0/24 role=edge status=active scope=vr1-off1 desc=VR1 Off1 office1-wan -- simulated ISP uplink (D-115) + 172.30.2.0/24 role=edge status=active scope=vr1-dc0 desc=vr1-dc0 simulated-ISP edge WAN (D-115) + 172.30.3.0/24 role=edge status=active scope=vr1-dc1 desc=vr1-dc1 simulated-ISP edge WAN (D-115) +== ip-addresses parent 10.12.68.0/22 (dc1 metal-admin band) == +count: 0 +== ip-addresses parent 172.31.0.4/30 (dc1 transit) == +count: 0 +== site vr1-dc1 == +count: 1 + vr1-dc1 status=active desc=Virtual Site for testing +== role transit == +count: 1 + transit diff --git a/docs/audit/dc1-rack-import-dryrun-20260721.txt b/docs/audit/dc1-rack-import-dryrun-20260721.txt new file mode 100644 index 0000000..1307c8c --- /dev/null +++ b/docs/audit/dc1-rack-import-dryrun-20260721.txt @@ -0,0 +1,14 @@ +# dc1 rack/transit importer DRY-RUN vs live apex -- 2026-07-21T23:53:24Z -- on office1-netbox +Target : http://localhost:8000 (SANDBOX) + +*** DRY RUN -- nothing will be written. Re-run with --commit. *** + +Transit prefix (office1<->vr1-dc1 mesh leg): + [dry-run] would CREATE 172.31.0.4/30 role=transit scope=dcim.site:vr1-dc1 + +Rack metal-admin static IP: + [dry-run] would CREATE 10.12.68.2/22 dns=vvr1-dc1 (metal-admin static band) + +================================================================== +would create: 2 already present: 0 +DRY RUN -- nothing was written. Re-run with --commit. diff --git a/docs/changelog-20260721-g12-dc1-authoring.md b/docs/changelog-20260721-g12-dc1-authoring.md new file mode 100644 index 0000000..1563673 --- /dev/null +++ b/docs/changelog-20260721-g12-dc1-authoring.md @@ -0,0 +1,64 @@ +# 2026-07-21 -- G12 [V] leg: vr1-dc1 substrate authoring (session changelog) + +Session scope: the G12 remaining [V] leg per the D-124 AMENDMENT (2026-07-21): +apex confirm-free -> vr1_dc1 vars -> dc1 substrate authoring -> gated build. +Branch: `dc-dc-g12-dc1-substrate` (off post-Stage-3-merge `main`, per the +stage-close rule). One changelog for the session (GA-R2/D1); every item +carries its revert. + +## Item 1 -- dc1 apex confirm-free capture (read-only; the [V] leg's first step) + +Read-only GETs on office1-netbox (token used on-host, never printed), captured +to `docs/audit/dc1-apex-confirm-20260721.txt`. Findings: + +- The six dc1 plane /22s ALREADY EXIST in the apex, scoped `vr1-dc1`, exactly + matching the ratified D-124-amendment scheme (provider-public 10.12.64.0/22 + ... replication 10.12.84.0/22, roles per D-101). Confirm-CONSISTENT: the + assignment landed at the original D-101/D-115 import; the ruled derivation + matches it. +- Transit 172.31.0.4/30 FREE (container 172.31.0.0/24 + dc0's /30 are its only + occupants; zero ip-addresses in the /30). +- Uplink 172.30.3.0/24 already registered to vr1-dc1 (D-115 edge import, + 2026-07-17). Rack IP 10.12.68.2 + forwarder .3 free (zero ip-addresses in + 10.12.68.0/22). Site `vr1-dc1` and role `transit` exist. + +Consequence: the ONLY apex write still owed for dc1 is the transit /30 + rack +metal-admin IP -- exactly the dc-rack-mgmt-import.py surface (dc0 precedent: +those are the only two objects dc0 registered either; transit endpoint IPs and +the D-131 forwarder alias are NOT apex objects for dc0 and dc1 mirrors that). + +Revert: delete the capture file (no state touched). + +## Item 2 -- dc-rack-mgmt-import.py: per-site support (vr1-dc0 + vr1-dc1) + +What: the importer was dc0-hardcoded (SITE_SLUG/METAL_ADMIN/RACK_DNS/desc +constants). Replaced with a `SITES` map (dc0: 10.12.8.0/22 / vvr1-dc0, ratified +2026-07-16; dc1: 10.12.68.0/22 / vvr1-dc1, D-124 amendment 2026-07-21) selected +by a new REQUIRED `--site {vr1-dc0,vr1-dc1}` flag (env: RACK_SITE). Explicit by +design: one DC's values can never land scoped to another site. An env-supplied +site is validated against SITES in code (argparse `choices` does not validate +env-default values). ROLE_SLUG / CONTAINER / D-120 band offsets stay global +(convention-wide). Also generalized dc0-specific error/usage text and fixed two +stale "(Cloud)" labels on the container messages (the container has been +172.31.0.0/24, not Cloud, since 2026-07-16). + +Why: unblocks the one owed dc1 apex write; the D-124 scheme "generalizes +per-leg" and the tool now encodes that instead of forking a dc1 copy. + +Harness: `tests/dc-rack-mgmt-import/` extended in the same change -- all CLI +cases pass `--site`, new cases: missing `--site` dies; bogus RACK_SITE env +dies; dc1 happy path (site id binding, /22 mask, dns vvr1-dc1); cross-site +guard (dc0 rack IP under --site vr1-dc1 rejected, zero writes); dc1 band edges +.2/.49 accepted, .1 gateway rejected; missing vr1-dc1 site precondition dies. +Structural pins moved from the retired globals to the SITES map. +**117/117 PASS** (was 96). + +Live dry-run vs the real apex (read-only, on office1-netbox): +`docs/audit/dc1-rack-import-dryrun-20260721.txt` -- whole preflight PASS, +plan = would-create exactly 172.31.0.4/30 (role transit, scope vr1-dc1) + +10.12.68.2/22 (dns vvr1-dc1). The `--commit` is a GATED operator step (apex +mutation), not run here. + +Revert: `git checkout main -- netbox/dc-rack-mgmt-import.py tests/dc-rack-mgmt-import/` +(restores the dc0-only tool); delete the two capture files. No apex state +touched (dry-run only). diff --git a/netbox/dc-rack-mgmt-import.py b/netbox/dc-rack-mgmt-import.py index 9011ba3..b6e7630 100644 --- a/netbox/dc-rack-mgmt-import.py +++ b/netbox/dc-rack-mgmt-import.py @@ -1,17 +1,21 @@ #!/usr/bin/env python3 """ Register the D-124 Office1-region <-> DC-rack MANAGEMENT TRANSIT addressing in -office1-netbox (the VR1 IPAM apex): the point-to-point transit on the office1<->dc0 +office1-netbox (the VR1 IPAM apex): the point-to-point transit on the office1<->DC mesh leg, plus the rack's metal-admin static IP. Two objects, nothing else. D-124 (ADOPTED 2026-07-16, Scheme A -- transit-numbered mesh) rules that the region<->rack MAAS control path rides a small point-to-point transit on the -office1<->dc0 mesh leg (NOT metal-admin, which D-100 keeps DC-local and node-facing). -The rack (vvr1-dc0) STRADDLES both legs: the transit (region-facing) and metal-admin -(node-facing, 10.12.8.0/22). So this tool registers exactly: +office1<->DC mesh leg (NOT metal-admin, which D-100 keeps DC-local and node-facing). +The rack VM STRADDLES both legs: the transit (region-facing) and metal-admin +(node-facing, the DC's own /22). The DC is selected with --site (REQUIRED -- +explicit so dc1 values can never land scoped to dc0 or vice versa; the scheme +"generalizes per-leg", D-124). Per-site facts (metal-admin /22, rack VM name) +live in SITES below -- dc0 per the 2026-07-16 ratification, dc1 per the D-124 +AMENDMENT 2026-07-21 (vr1-dc1 addressing RATIFIED). So this tool registers exactly: - 1. the transit prefix role=transit, scope=dcim.site:vr1-dc0 (the /30 or /31) - 2. the rack ip-address /22 in metal-admin, D-120 static band .2-.49 + 1. the transit prefix role=transit, scope=dcim.site:<--site> (the /30 or /31) + 2. the rack ip-address /22 in that DC's metal-admin, D-120 static band .2-.49 Both LITERALS ARE OPERATOR INPUTS (--transit-cidr / --rack-ip): D-124, like D-115/ D-117, rules the SCHEME and leaves the actual CIDR + IP to office1-netbox (the apex; @@ -47,18 +51,21 @@ BEFORE this runs; a missing/collision container makes the tool DIE (fail-loud is the backstop for the candidate block being picked against a stale draft). This gates ACCEPTANCE only -- NetBox auto-nests by CIDR, we set no parent. - * transit prefix SCOPE = dcim.site:vr1-dc0 (RULED 2026-07-16: "mirror the edge" -- + * transit prefix SCOPE = dcim.site:<--site> (RULED 2026-07-16: "mirror the edge" -- site-scoped like the D-115 DC-edge /24s, 172.30.2.0/24 -> vr1-dc0). - * The rack IP band (metal-admin 10.12.8.0/22, static .2-.49, gw .1) is enforced + * The rack IP band (the DC's metal-admin /22, static .2-.49, gw .1) is enforced ARITHMETICALLY (D-120/D-124), NOT via a prefix-object lookup: metal-admin is not registered as a /22 prefix in the apex draft, and the band is a convention, not a container. This asymmetry with the transit check is intentional. -Usage (on office1-netbox / through a tunnel, with the sandbox token). D-124 values -operator-ratified 2026-07-16 (transit supernet 172.31.0.0/24, transit /30 -172.31.0.0/30, rack IP 10.12.8.2) -- confirm free against the LIVE apex first: +Usage (on office1-netbox / through a tunnel, with the sandbox token). Ratified values -- +confirm free against the LIVE apex first: + dc0 (ratified 2026-07-16: transit /30 172.31.0.0/30, rack IP 10.12.8.2; APPLIED): NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN= \ - python3 netbox/dc-rack-mgmt-import.py --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.2 + python3 netbox/dc-rack-mgmt-import.py --site vr1-dc0 --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.2 + dc1 (D-124 AMENDMENT ratified 2026-07-21: transit /30 172.31.0.4/30, rack IP 10.12.68.2): + NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN= \ + python3 netbox/dc-rack-mgmt-import.py --site vr1-dc1 --transit-cidr 172.31.0.4/30 --rack-ip 10.12.68.2 ... same, add --commit, to write. """ import argparse @@ -78,18 +85,32 @@ ROLE_SLUG = "transit" # the transit's OWN role (D-124); a precondition here CONTAINER = "172.31.0.0/24" # D-124 dedicated transit supernet (operator-pinned 2026-07-16); transit /30 must be subnet_of this (precondition) -SITE_SLUG = "vr1-dc0" # the transit prefix is site-scoped here (mirrors the D-115 edge /24s) STATUS = "active" # a real segment on the wire -# metal-admin band arithmetic (D-052/D-124). The rack's metal-admin IP lands in the -# D-120 static band .2-.49 of this /22, and NOT the .1 gateway. -METAL_ADMIN = "10.12.8.0/22" +# D-120 static band arithmetic: the rack's metal-admin IP lands in .2-.49 of the DC's +# metal-admin /22, and NOT the .1 gateway. Band offsets are convention-global; the /22 +# itself is per-site (SITES below). STATIC_BAND_LOW = 2 # .2 (first static site-services address, D-120) STATIC_BAND_HIGH = 49 # .49 (last static site-services address, D-120) -RACK_DNS = "vvr1-dc0" # the rack-controller VM (D-124 cloudinit-vm on the two legs) -TRANSIT_DESC = "office1<->dc0 management transit -- region<->rack MAAS control path (D-124 Scheme A)" -RACK_DESC = "vr1-dc0 MAAS rack controller (vvr1-dc0) metal-admin static IP (D-124; D-120 static band)" +# Per-site facts. The transit prefix is scoped dcim.site: (mirrors the D-115 edge +# /24s). dc0: ratified 2026-07-16 (applied to the apex 2026-07-16/17). dc1: D-124 +# AMENDMENT ratified 2026-07-21 -- metal-admin 10.12.68.0/22 within the vr1-dc1 +# supernet 10.12.64.0/19 (D-115), rack VM vvr1-dc1. +SITES = { + "vr1-dc0": { + "metal_admin": "10.12.8.0/22", + "rack_dns": "vvr1-dc0", + "transit_desc": "office1<->dc0 management transit -- region<->rack MAAS control path (D-124 Scheme A)", + "rack_desc": "vr1-dc0 MAAS rack controller (vvr1-dc0) metal-admin static IP (D-124; D-120 static band)", + }, + "vr1-dc1": { + "metal_admin": "10.12.68.0/22", + "rack_dns": "vvr1-dc1", + "transit_desc": "office1<->dc1 management transit -- region<->rack MAAS control path (D-124 Scheme A)", + "rack_desc": "vr1-dc1 MAAS rack controller (vvr1-dc1) metal-admin static IP (D-124 amendment 2026-07-21; D-120 static band)", + }, +} def die(msg: str): @@ -146,21 +167,21 @@ except ValueError as exc: die(f"--transit-cidr {raw!r} is not a valid network (host bits set?): {exc}") if net.version != 4: - die(f"--transit-cidr {raw} must be IPv4 (the office1<->dc0 transit is v4).") + die(f"--transit-cidr {raw} must be IPv4 (the office1<->DC transits are v4).") if net.prefixlen not in (30, 31): die(f"--transit-cidr {raw} must be a /30 or /31 point-to-point (got /{net.prefixlen}).") return net def parse_rack_ip(raw: str) -> ipaddress.IPv4Address: - """Accept a bare host (10.12.8.X) or a masked form; if masked, the mask MUST be the + """Accept a bare host or a masked form; if masked, the mask MUST be the metal-admin /22. Returns the host address; band placement is checked in preflight.""" try: if "/" in raw: iface = ipaddress.ip_interface(raw) if iface.network.prefixlen != 22: die(f"--rack-ip {raw} carries /{iface.network.prefixlen}; metal-admin is a /22. " - f"Pass a bare host (10.12.8.X) or the /22 form.") + f"Pass a bare host or the /22 form.") host = iface.ip else: host = ipaddress.ip_address(raw) @@ -173,12 +194,16 @@ def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + ap.add_argument("--site", choices=sorted(SITES), default=os.environ.get("RACK_SITE"), + help="REQUIRED. Which DC's transit + rack IP to register (env: RACK_SITE). " + "Explicit -- never inferred -- so one DC's values cannot land scoped " + "to another site.") ap.add_argument("--transit-cidr", default=os.environ.get("TRANSIT_CIDR"), - help="REQUIRED. The /30 or /31 point-to-point for the office1<->dc0 " + help="REQUIRED. The /30 or /31 point-to-point for the office1<->DC " "transit leg (operator-supplied, NetBox-assigned; env: TRANSIT_CIDR).") ap.add_argument("--rack-ip", default=os.environ.get("RACK_IP"), - help="REQUIRED. The rack's metal-admin static IP within 10.12.8.0/22, " - "static band .2-.49 (operator-supplied; env: RACK_IP).") + help="REQUIRED. The rack's metal-admin static IP within the site's " + "metal-admin /22, static band .2-.49 (operator-supplied; env: RACK_IP).") ap.add_argument("--commit", action="store_true", help="WRITE. Default is a DRY RUN that writes nothing.") ap.add_argument("--yes-write-upstream", action="store_true", @@ -192,6 +217,18 @@ # Inputs are args-or-env, so they cannot be argparse required=True (that would break # the env fallback). Hand-roll the missing checks, each with its own die. + if not args.site: + die("--site (or RACK_SITE) is REQUIRED -- the target DC is never inferred " + "(one DC's transit/rack values must not land scoped to another site).") + if args.site not in SITES: + # argparse `choices` does not validate a value arriving via the env-var default. + die(f"--site {args.site!r} is not a known DC (expected one of: {', '.join(sorted(SITES))}).") + site_cfg = SITES[args.site] + site_slug = args.site + metal_admin = site_cfg["metal_admin"] + rack_dns = site_cfg["rack_dns"] + transit_desc = site_cfg["transit_desc"] + rack_desc = site_cfg["rack_desc"] if not args.transit_cidr: die("--transit-cidr (or TRANSIT_CIDR) is REQUIRED -- the NetBox-assigned transit " "/30 or /31. No literal is invented in-repo (D-124).") @@ -216,24 +253,27 @@ transit = parse_transit_cidr(args.transit_cidr) container = ipaddress.ip_network(CONTAINER) if not transit.subnet_of(container): - die(f"transit {transit} is outside the container {CONTAINER} (Cloud) -- refusing to " - f"place it. If the transit is carved elsewhere, that is an operator/D-124 call.") + die(f"transit {transit} is outside the container {CONTAINER} (transit supernet) -- " + f"refusing to place it. If the transit is carved elsewhere, that is an " + f"operator/D-124 call.") - # (b) Rack IP band: within metal-admin /22, in the D-120 static band .2-.49, NOT the - # .1 gateway (a distinct die so the ".1 rejected" property is unambiguous). + # (b) Rack IP band: within the site's metal-admin /22, in the D-120 static band + # .2-.49, NOT the .1 gateway (a distinct die so the ".1 rejected" property is + # unambiguous). rack = parse_rack_ip(args.rack_ip) - metal = ipaddress.ip_network(METAL_ADMIN) + metal = ipaddress.ip_network(metal_admin) if rack not in metal: - die(f"rack IP {rack} is outside metal-admin {METAL_ADMIN} -- refusing to place it.") + die(f"rack IP {rack} is outside metal-admin {metal_admin} ({site_slug}) -- refusing " + f"to place it.") gateway = metal.network_address + 1 band_low = metal.network_address + STATIC_BAND_LOW band_high = metal.network_address + STATIC_BAND_HIGH if rack == gateway: - die(f"rack IP {rack} is the .1 GATEWAY of {METAL_ADMIN} -- refusing (D-120: .1 is the " + die(f"rack IP {rack} is the .1 GATEWAY of {metal_admin} -- refusing (D-120: .1 is the " f"site gateway, not a static-service address).") if not (band_low <= rack <= band_high): die(f"rack IP {rack} is outside the D-120 static band {band_low}-{band_high} of " - f"{METAL_ADMIN} -- the rack's metal-admin IP must land in .2-.49 (not the dynamic/" + f"{metal_admin} -- the rack's metal-admin IP must land in .2-.49 (not the dynamic/" f"node bands).") rack_addr = f"{rack}/{metal.prefixlen}" # NetBox stores the host WITH the plane mask @@ -247,12 +287,12 @@ f"place the transit prefix with no role.") if nb.one("ipam/prefixes", prefix=CONTAINER) is None: - die(f"container {CONTAINER} (Cloud) absent in the apex -- refusing to place the transit " - f"in an unallocated supernet.") + die(f"container {CONTAINER} (transit supernet) absent in the apex -- refusing to place " + f"the transit in an unallocated supernet.") - site = nb.one("dcim/sites", slug=SITE_SLUG) + site = nb.one("dcim/sites", slug=site_slug) if site is None: - die(f"site '{SITE_SLUG}' absent -- cannot scope the transit prefix. The DC site is a " + die(f"site '{site_slug}' absent -- cannot scope the transit prefix. The DC site is a " f"precondition (it already exists in the apex); this tool never creates it.") # (d) Present-state (idempotency) -- resolved up front, part of the whole-plan gate. @@ -264,19 +304,19 @@ # ---- CREATE (transit prefix, then rack ip-address) -- only reached once the WHOLE # plan above is viable, so this loop cannot half-write the apex. ---- - print("\nTransit prefix (office1<->dc0 mesh leg):") + print(f"\nTransit prefix (office1<->{site_slug} mesh leg):") if transit_present: print(f" EXISTS {transit_str}") existing += 1 elif not args.commit: print(f" [dry-run] would CREATE {transit_str} role={ROLE_SLUG} " - f"scope=dcim.site:{SITE_SLUG}") + f"scope=dcim.site:{site_slug}") created += 1 else: payload = {"prefix": transit_str, "role": role["id"], "status": STATUS, - "description": TRANSIT_DESC, "scope_type": "dcim.site", "scope_id": site["id"]} + "description": transit_desc, "scope_type": "dcim.site", "scope_id": site["id"]} o = nb.create("ipam/prefixes", payload) - print(f" CREATED {transit_str} (id={o['id']}) role={ROLE_SLUG} scope={SITE_SLUG}") + print(f" CREATED {transit_str} (id={o['id']}) role={ROLE_SLUG} scope={site_slug}") created += 1 print("\nRack metal-admin static IP:") @@ -284,13 +324,13 @@ print(f" EXISTS {rack_addr}") existing += 1 elif not args.commit: - print(f" [dry-run] would CREATE {rack_addr} dns={RACK_DNS} (metal-admin static band)") + print(f" [dry-run] would CREATE {rack_addr} dns={rack_dns} (metal-admin static band)") created += 1 else: payload = {"address": rack_addr, "status": STATUS, - "dns_name": RACK_DNS, "description": RACK_DESC} + "dns_name": rack_dns, "description": rack_desc} o = nb.create("ipam/ip-addresses", payload) - print(f" CREATED {rack_addr} (id={o['id']}) dns={RACK_DNS}") + print(f" CREATED {rack_addr} (id={o['id']}) dns={rack_dns}") created += 1 verb = "would create" if not args.commit else "created" diff --git a/tests/dc-rack-mgmt-import/run-tests.sh b/tests/dc-rack-mgmt-import/run-tests.sh index e91e828..5b1a742 100644 --- a/tests/dc-rack-mgmt-import/run-tests.sh +++ b/tests/dc-rack-mgmt-import/run-tests.sh @@ -29,13 +29,16 @@ NETBOX_URL= NETBOX_TOKEN= python3 "$S" --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.5 \ >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing NETBOX_URL/TOKEN must fail" -# NO INVENTED LITERAL -- both inputs are REQUIRED (no default CIDR/IP baked in) -NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --rack-ip 10.12.8.5 \ +# NO INVENTED LITERAL -- all three inputs are REQUIRED (no default site/CIDR/IP baked in) +NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --site vr1-dc0 --rack-ip 10.12.8.5 \ >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing --transit-cidr must fail (no invented CIDR)" -NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --transit-cidr 172.31.0.0/30 \ +NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --site vr1-dc0 --transit-cidr 172.31.0.0/30 \ >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing --rack-ip must fail (no invented IP)" +NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN=x python3 "$S" --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.5 \ + >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing --site must fail (target DC never inferred)" grep -q 'default=os.environ.get("TRANSIT_CIDR")' "$S" && ok || bad "lost the TRANSIT_CIDR env fallback" grep -q 'default=os.environ.get("RACK_IP")' "$S" && ok || bad "lost the RACK_IP env fallback" +grep -q 'default=os.environ.get("RACK_SITE")' "$S" && ok || bad "lost the RACK_SITE env fallback" # guard against a baked-in transit /30 or /31 literal masquerading as a default grep -qE '=\s*"10\.[0-9]+\.[0-9]+\.[0-9]+/3[01]"' "$S" && bad "a /30 or /31 literal is baked in -- must be an INPUT" || ok @@ -47,7 +50,7 @@ grep -q 'yes-write-upstream' "$S" && ok || bad "lost the --yes-write-upstream gate" grep -q 'SANDBOX_HOSTS' "$S" && ok || bad "lost the SANDBOX_HOSTS gate" out="$(NETBOX_URL=https://netbox.baldurkeep.com NETBOX_TOKEN=x python3 "$S" \ - --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.5 --commit 2>&1)" + --site vr1-dc0 --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.5 --commit 2>&1)" printf '%s' "$out" | grep -q "REFUSING to --commit" && ok \ || bad "did NOT refuse a --commit to a non-sandbox host (it would have written to production)" @@ -64,12 +67,15 @@ grep -q "outside metal-admin" "$S" && ok || bad "lost the metal-admin containment check" grep -q "must be a /30 or /31" "$S" && ok || bad "lost the /30-or-/31 shape check" -# THE D-124 SCHEME VALUES -- role, container, site scope, metal-admin band, rack dns. +# THE D-124 SCHEME VALUES -- role, container, per-site scope/metal-admin/rack dns +# (dc0 ratified 2026-07-16; dc1 per the D-124 AMENDMENT 2026-07-21). grep -qF 'ROLE_SLUG = "transit"' "$S" && ok || bad "transit role slug changed" grep -qF 'CONTAINER = "172.31.0.0/24"' "$S" && ok || bad "container is not 172.31.0.0/24 (D-124 dedicated transit supernet, operator-pinned)" -grep -qF 'SITE_SLUG = "vr1-dc0"' "$S" && ok || bad "transit site scope is not vr1-dc0" -grep -qF 'METAL_ADMIN = "10.12.8.0/22"' "$S" && ok || bad "metal-admin is not 10.12.8.0/22" -grep -qF 'RACK_DNS = "vvr1-dc0"' "$S" && ok || bad "rack dns name is not vvr1-dc0" +grep -qF '"vr1-dc0": {' "$S" && grep -qF '"vr1-dc1": {' "$S" && ok || bad "SITES map lost a DC entry" +grep -qF '"metal_admin": "10.12.8.0/22"' "$S" && ok || bad "dc0 metal-admin is not 10.12.8.0/22" +grep -qF '"metal_admin": "10.12.68.0/22"' "$S" && ok || bad "dc1 metal-admin is not 10.12.68.0/22 (D-124 amendment)" +grep -qF '"rack_dns": "vvr1-dc0"' "$S" && ok || bad "dc0 rack dns name is not vvr1-dc0" +grep -qF '"rack_dns": "vvr1-dc1"' "$S" && ok || bad "dc1 rack dns name is not vvr1-dc1" grep -q '"scope_type": "dcim.site"' "$S" && ok || bad "the transit prefix is not dcim.site-scoped" # This tool must NOT create the role/container/site -- they are preconditions. diff --git a/tests/dc-rack-mgmt-import/test_logic.py b/tests/dc-rack-mgmt-import/test_logic.py index 63f0677..fc25446 100644 --- a/tests/dc-rack-mgmt-import/test_logic.py +++ b/tests/dc-rack-mgmt-import/test_logic.py @@ -104,23 +104,30 @@ ok(label) -# Full-precondition fixture builders (transit role + transit container + vr1-dc0 site). +# Full-precondition fixture builders (transit role + transit container + DC sites). ROLE = {"slug": "transit", "name": "Transit", "id": 7} CONTAINER = {"prefix": "172.31.0.0/24", "id": 10} SITE_DC0 = {"slug": "vr1-dc0", "name": "VR1 DC0", "id": 20} +SITE_DC1 = {"slug": "vr1-dc1", "name": "VR1 DC1", "id": 21} + +# dc1 ratified values (D-124 AMENDMENT 2026-07-21): transit 172.31.0.4/30 (next /30 in +# the container), rack IP in metal-admin 10.12.68.0/22's D-120 static band. +TRANSIT_DC1 = "172.31.0.4/30" +RACK_DC1 = "10.12.68.2" +RACK_DC1_ADDR = "10.12.68.2/22" def full_fake(prefixes_extra=(), ip_extra=()): return fake_netbox.FakeNB( roles=[ROLE], prefixes=[CONTAINER, *prefixes_extra], - sites=[SITE_DC0], + sites=[SITE_DC0, SITE_DC1], ip_addresses=list(ip_extra), ) def base_args(extra=()): - return ["--transit-cidr", TRANSIT, "--rack-ip", RACK, *extra] + return ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", RACK, *extra] os.environ["NETBOX_URL"] = "http://10.10.1.10:8000" # a known sandbox (guard passes) @@ -174,7 +181,7 @@ # ----------------------------------------------------------------------------- fk = full_fake() with captured_stdout(): - rc = run_main(["--transit-cidr", TRANSIT31, "--rack-ip", RACK, "--commit"], fk) + rc = run_main(["--site", "vr1-dc0", "--transit-cidr", TRANSIT31, "--rack-ip", RACK, "--commit"], fk) check(rc == 0 and len(fk.creates) == 2, "a /31 transit is accepted and writes both objects", str((rc, len(fk.creates)))) @@ -214,7 +221,7 @@ # ----------------------------------------------------------------------------- fk = full_fake() run_dies("half-write: rack IP == .1 gateway is REJECTED before any write", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.1", "--commit"], fk) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.1", "--commit"], fk) check(len(fk.creates) == 0, "half-write: the rejected run wrote NOTHING (transit not created)") # ----------------------------------------------------------------------------- @@ -236,61 +243,70 @@ # 8. Transit CIDR shape / placement rejects. # ----------------------------------------------------------------------------- run_dies("a transit OUTSIDE the transit container is REJECTED", - ["--transit-cidr", "192.168.0.0/30", "--rack-ip", RACK, "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", "192.168.0.0/30", "--rack-ip", RACK, "--commit"], full_fake()) run_dies("a /29 transit is REJECTED (not point-to-point)", - ["--transit-cidr", "172.31.0.0/29", "--rack-ip", RACK, "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", "172.31.0.0/29", "--rack-ip", RACK, "--commit"], full_fake()) run_dies("a /32 transit is REJECTED", - ["--transit-cidr", "172.31.0.1/32", "--rack-ip", RACK, "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", "172.31.0.1/32", "--rack-ip", RACK, "--commit"], full_fake()) run_dies("a host-bits-set transit (172.31.0.1/30) is REJECTED", - ["--transit-cidr", "172.31.0.1/30", "--rack-ip", RACK, "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", "172.31.0.1/30", "--rack-ip", RACK, "--commit"], full_fake()) run_dies("a non-CIDR transit is REJECTED", - ["--transit-cidr", "not-a-cidr", "--rack-ip", RACK, "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", "not-a-cidr", "--rack-ip", RACK, "--commit"], full_fake()) # each of the above must have written nothing -- prove it once with a fresh fake _fk = full_fake() run_dies("bad transit writes nothing (proof)", - ["--transit-cidr", "172.31.0.0/29", "--rack-ip", RACK, "--commit"], _fk) + ["--site", "vr1-dc0", "--transit-cidr", "172.31.0.0/29", "--rack-ip", RACK, "--commit"], _fk) check(len(_fk.creates) == 0, "the rejected bad-transit run wrote NOTHING") # ----------------------------------------------------------------------------- # 9. Rack-IP band rejects. # ----------------------------------------------------------------------------- run_dies("rack IP OUTSIDE metal-admin 10.12.8.0/22 is REJECTED", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.13.0.5", "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.13.0.5", "--commit"], full_fake()) run_dies("rack IP == .1 gateway is REJECTED", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.1", "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.1", "--commit"], full_fake()) run_dies("rack IP == .0 network address is REJECTED", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.0", "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.0", "--commit"], full_fake()) run_dies("rack IP in-/22-but-above-static-band (.50) is REJECTED", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.50", "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.50", "--commit"], full_fake()) run_dies("rack IP in-/22-but-in-a-higher-/24 (10.12.9.5) is REJECTED", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.9.5", "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.12.9.5", "--commit"], full_fake()) run_dies("rack IP with a non-/22 mask is REJECTED", - ["--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.5/24", "--commit"], full_fake()) + ["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", "10.12.8.5/24", "--commit"], full_fake()) # accepted: the boundaries of the static band (.2 low, .49 high) both write. for edge_ip in ("10.12.8.2", "10.12.8.49"): fk = full_fake() with captured_stdout(): - rc = run_main(["--transit-cidr", TRANSIT, "--rack-ip", edge_ip, "--commit"], fk) + rc = run_main(["--site", "vr1-dc0", "--transit-cidr", TRANSIT, "--rack-ip", edge_ip, "--commit"], fk) check(rc == 0 and len(fk.creates) == 2, f"rack IP band edge {edge_ip} is ACCEPTED", str((rc, len(fk.creates)))) # ----------------------------------------------------------------------------- # 10. NO INVENTED LITERAL -- missing input fails loud (does not guess a value). # ----------------------------------------------------------------------------- -run_dies("missing --transit-cidr fails loud", ["--rack-ip", RACK], full_fake()) -run_dies("missing --rack-ip fails loud", ["--transit-cidr", TRANSIT], full_fake()) +run_dies("missing --transit-cidr fails loud", ["--site", "vr1-dc0", "--rack-ip", RACK], full_fake()) +run_dies("missing --rack-ip fails loud", ["--site", "vr1-dc0", "--transit-cidr", TRANSIT], full_fake()) +run_dies("missing --site fails loud (target DC is never inferred)", + ["--transit-cidr", TRANSIT, "--rack-ip", RACK], full_fake()) -# env fallback works (args-or-env): both via env, no flags. +# env fallback works (args-or-env): all three via env, no flags. +os.environ["RACK_SITE"] = "vr1-dc0" os.environ["TRANSIT_CIDR"] = TRANSIT os.environ["RACK_IP"] = RACK fk = full_fake() with captured_stdout(): rc = run_main(["--commit"], fk) -check(rc == 0 and len(fk.creates) == 2, "env TRANSIT_CIDR/RACK_IP are honored (args-or-env)", +check(rc == 0 and len(fk.creates) == 2, "env RACK_SITE/TRANSIT_CIDR/RACK_IP are honored (args-or-env)", str((rc, len(fk.creates)))) del os.environ["TRANSIT_CIDR"] del os.environ["RACK_IP"] +# a BOGUS env site dies (argparse choices does not validate env-supplied defaults). +os.environ["RACK_SITE"] = "vr9-dc9" +run_dies("bogus RACK_SITE env value fails loud (choices bypass covered)", + ["--transit-cidr", TRANSIT, "--rack-ip", RACK], full_fake()) +del os.environ["RACK_SITE"] + # ----------------------------------------------------------------------------- # 11. Missing NETBOX_URL/TOKEN fails loud (does not guess a target). # ----------------------------------------------------------------------------- @@ -321,16 +337,64 @@ os.environ["NETBOX_URL"] = "http://10.10.1.10:8000" # ----------------------------------------------------------------------------- -# 13. Structural pins (a changed constant a behavioral test alone would miss). +# 13. Per-site behavior: --site vr1-dc1 (D-124 AMENDMENT 2026-07-21). +# ----------------------------------------------------------------------------- +# dc1 happy path: binds vr1-dc1's site id, dc1 metal-admin mask, dns vvr1-dc1. +fk = full_fake() +with captured_stdout(): + rc = run_main(["--site", "vr1-dc1", "--transit-cidr", TRANSIT_DC1, + "--rack-ip", RACK_DC1, "--commit"], fk) +check(rc == 0 and len(fk.creates) == 2, "dc1 commit writes both objects", + str((rc, len(fk.creates)))) +pfx1 = next(pl for pp, pl in fk.creates if pp == "ipam/prefixes") +ipa1 = next(pl for pp, pl in fk.creates if pp == "ipam/ip-addresses") +check(pfx1.get("prefix") == TRANSIT_DC1, "dc1 transit prefix is 172.31.0.4/30", + str(pfx1.get("prefix"))) +check(pfx1.get("scope_id") == 21, "dc1 transit prefix binds vr1-dc1's site id (21), NOT dc0's", + str(pfx1.get("scope_id"))) +check(ipa1.get("address") == RACK_DC1_ADDR, "dc1 rack IP stored with the 10.12.68.0/22 mask", + str(ipa1.get("address"))) +check(ipa1.get("dns_name") == "vvr1-dc1", "dc1 rack IP dns_name is vvr1-dc1", + str(ipa1.get("dns_name"))) +check("vr1-dc1" in ipa1.get("description", ""), "dc1 rack IP description names vr1-dc1") + +# CROSS-SITE GUARD: dc0's rack IP under --site vr1-dc1 is outside dc1's metal-admin -> die. +fk = full_fake() +run_dies("dc0 rack IP under --site vr1-dc1 is REJECTED (band is per-site)", + ["--site", "vr1-dc1", "--transit-cidr", TRANSIT_DC1, "--rack-ip", RACK, "--commit"], fk) +check(len(fk.creates) == 0, "the cross-site rejected run wrote NOTHING") +# and dc1's band edges hold on the dc1 /22. +for edge_ip in ("10.12.68.2", "10.12.68.49"): + fk = full_fake() + with captured_stdout(): + rc = run_main(["--site", "vr1-dc1", "--transit-cidr", TRANSIT_DC1, + "--rack-ip", edge_ip, "--commit"], fk) + check(rc == 0 and len(fk.creates) == 2, f"dc1 rack IP band edge {edge_ip} is ACCEPTED", + str((rc, len(fk.creates)))) +run_dies("dc1 rack IP .1 gateway (10.12.68.1) is REJECTED", + ["--site", "vr1-dc1", "--transit-cidr", TRANSIT_DC1, "--rack-ip", "10.12.68.1", + "--commit"], full_fake()) +# a dc1 run against a fake WITHOUT the vr1-dc1 site dies (site precondition is per-site). +fk = fake_netbox.FakeNB(roles=[ROLE], prefixes=[CONTAINER], sites=[SITE_DC0]) +run_dies("missing vr1-dc1 site is REJECTED for --site vr1-dc1", + ["--site", "vr1-dc1", "--transit-cidr", TRANSIT_DC1, "--rack-ip", RACK_DC1, + "--commit"], fk) +check(len(fk.creates) == 0, "missing-dc1-site run wrote nothing") + +# ----------------------------------------------------------------------------- +# 14. Structural pins (a changed constant a behavioral test alone would miss). # ----------------------------------------------------------------------------- check(T.ROLE_SLUG == "transit", "ROLE_SLUG is 'transit'") check(T.CONTAINER == "172.31.0.0/24", "CONTAINER is 172.31.0.0/24 (dedicated transit supernet)") -check(T.SITE_SLUG == "vr1-dc0", "SITE_SLUG is vr1-dc0 (transit site scope)") -check(T.METAL_ADMIN == "10.12.8.0/22", "METAL_ADMIN is 10.12.8.0/22") +check(set(T.SITES) == {"vr1-dc0", "vr1-dc1"}, "SITES covers exactly vr1-dc0 + vr1-dc1") +check(T.SITES["vr1-dc0"]["metal_admin"] == "10.12.8.0/22", "dc0 metal-admin is 10.12.8.0/22") +check(T.SITES["vr1-dc1"]["metal_admin"] == "10.12.68.0/22", + "dc1 metal-admin is 10.12.68.0/22 (D-124 amendment 2026-07-21)") check(T.STATIC_BAND_LOW == 2 and T.STATIC_BAND_HIGH == 49, "static band is .2-.49 (D-120)") check(T.STATUS == "active", "STATUS is active") -check(T.RACK_DNS == "vvr1-dc0", "RACK_DNS is vvr1-dc0") +check(T.SITES["vr1-dc0"]["rack_dns"] == "vvr1-dc0", "dc0 rack dns is vvr1-dc0") +check(T.SITES["vr1-dc1"]["rack_dns"] == "vvr1-dc1", "dc1 rack dns is vvr1-dc1") check(T.SANDBOX_HOSTS == {"localhost", "127.0.0.1", "10.10.1.10"}, "SANDBOX_HOSTS matches the sibling importers") check("get_nb" in dir(T) and callable(T.get_nb), "get_nb() injection seam exists")