diff --git a/docs/changelog-20260717-netbox-apex-recovery-edge-wan-import.md b/docs/changelog-20260717-netbox-apex-recovery-edge-wan-import.md index 37de7fe..7687d52 100644 --- a/docs/changelog-20260717-netbox-apex-recovery-edge-wan-import.md +++ b/docs/changelog-20260717-netbox-apex-recovery-edge-wan-import.md @@ -1,4 +1,4 @@ -# 2026-07-17 -- office1-netbox apex recovery + restart policy + edge-WAN import (Block A) +# 2026-07-17 -- office1-netbox apex recovery + restart policy + full D-124/edge-WAN import (Blocks A + B) ## Context @@ -26,16 +26,29 @@ Token read server-side (`sudo cat /root/netbox-secrets/api.token` ON the apex host) -- never entered agent context. Sandbox target (localhost) -- no upstream write; `netbox.baldurkeep.com` untouched. -## BLOCKED -- Block B (D-124 rack transit + rack IP) +## Block B (D-124 rack transit + rack IP) -- COMPLETE (operator chose: write the seeder) -`dc-rack-mgmt-import.py` fails its precondition: **`role 'transit' absent`**. The D-124 transit role + -the `172.31.0.0/24` container have **no seeder** (the importer is deliberately die-if-absent). Needs a -`transit`-role + `172.31.0.0/24`-container seed first -- pending an operator decision on tooling (write a -`d124-transit-seed` + harness, extend `d115-office-carve`, or defer -- Block B is decoupled from the -deploy: no netbox provider in the tree). +`dc-rack-mgmt-import.py` was die-if-absent on `role 'transit'` + the `172.31.0.0/24` container, which had +no seeder. Operator ruled: write one. + +- **`netbox/d124-transit-seed.py` (NEW)** + **`tests/d124-transit-seed/run-tests.sh` (21/21)** -- mirrors + `d115-office-carve` (NB client, sandbox gate, WAF UA, dry-by-default, `--yes-write-upstream`). Seeds ONLY + the `transit` role + the `172.31.0.0/24` UNSCOPED container (mirrors edge's `/16`: the container is shared, + each DC's link is scoped by `dc-rack-mgmt`). The harness includes a **cross-file consistency check** so the + seeded role/container can never drift from `dc-rack-mgmt-import`'s required `ROLE_SLUG`/`CONTAINER`. +- **`d124-transit-seed.py --commit`:** CREATED role `transit` (id=30) + `172.31.0.0/24` (id=137, container). +- **`dc-rack-mgmt-import.py --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.2 --commit`:** CREATED + `172.31.0.0/30` (id=138, role=transit, scope=vr1-dc0) + `10.12.8.2/22` (id=3, dns=vvr1-dc0). Matches the + D-124 ruled values + the `vr1_dc0_rack_*` tfvars. Re-run dry-run confirms both EXIST (0 to create). + +So the full NetBox D-124/edge-WAN import is COMPLETE in the apex. Copied importers cleaned from the apex +`/tmp`. Sandbox-only throughout (`netbox.baldurkeep.com` untouched); token read server-side (never in +agent context). ## Revert -- Edge /24s: delete prefixes id=135,136 in NetBox (or `dc-edge-wan-import` has no un-import; delete via UI/API). +- Apex objects (delete via NetBox UI/API -- the importers have no un-import): edge /24s id=135,136; + transit /30 id=138; rack IP id=3; container 172.31.0.0/24 id=137; role `transit` id=30. +- Tooling: `git rm netbox/d124-transit-seed.py tests/d124-transit-seed/`. - Restart policy: restore `docker-compose.override.yml.bak-20260717` + `docker compose up -d`. - Apex up/down is operational state, not a repo change. diff --git a/docs/dc0-deploy-readiness.md b/docs/dc0-deploy-readiness.md index ec75287..ff6d233 100644 --- a/docs/dc0-deploy-readiness.md +++ b/docs/dc0-deploy-readiness.md @@ -81,12 +81,11 @@ - MAAS (D): discovers the inner node VMs (needs C2.3). ### C4. IPAM record hygiene (parallel, NON-blocking -- now runnable via `ssh office1-netbox`) -5. **D-124 NetBox import** into office1-netbox (transit role + `172.31.0.0/24` carve + rack IP): - run ON the netbox host (token local at `/root/netbox-secrets/api.token`) -- - `python3 netbox/dc-rack-mgmt-import.py --transit-cidr 172.31.0.0/30 --rack-ip 10.12.8.2` (dry-run; - add `--commit`). tofu plan does NOT need this (no netbox provider); it is IPAM correctness. -6. **Edge-WAN import** (`172.30.2.0/24`, `.3.0/24`) via `netbox/dc-edge-wan-import.py` (after - `d115-office-carve.py` seeds Office+Edge roles). +5. **D-124 NetBox import** -- DONE 2026-07-17. `netbox/d124-transit-seed.py` (NEW) seeded role `transit` + + `172.31.0.0/24` container; `dc-rack-mgmt-import` placed `172.31.0.0/30` (vr1-dc0) + `10.12.8.2/22` + (rack). Ran on the apex host, token server-side, sandbox-only. +6. **Edge-WAN import** -- DONE 2026-07-17. `dc-edge-wan-import` created `172.30.2.0/24` + `172.30.3.0/24` + (edge). (Also recovered the apex: netbox-docker was down; brought up + `restart: unless-stopped` added.) ### C5. Record-hygiene sweep (NON-blocking; queued from the Stage-3 adversarial review) 7. Sweep the queued amendments into `docs/design-decisions.md`: D-122 MAAS-bullet + site-down diff --git a/docs/session-ledger.md b/docs/session-ledger.md index 518712e..705412c 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -142,11 +142,12 @@ **NetBox apex recovery + edge-WAN import (2026-07-17).** Found the IPAM apex DOWN (netbox-docker cleanly exited ~3h prior, no auto-restart). Brought it up (`docker compose up -d`, HTTP 302) + added `restart: unless-stopped` to all 5 services (survives the bookend reboot; override backed up on host). -Block A COMMITTED: `dc-edge-wan-import` created 172.30.2.0/24 (vr1-dc0) + 172.30.3.0/24 (vr1-dc1), edge -role. Token read server-side (never in agent context); sandbox-only (upstream untouched). **Block B -(D-124 rack) BLOCKED:** `dc-rack-mgmt-import` dies on `role 'transit' absent` -- the transit role + -172.31.0.0/24 container have NO seeder. Pending operator decision (write d124-transit-seed+harness / -extend d115-office-carve / defer -- Block B is decoupled from the deploy). See +Both blocks COMMITTED (gated, dry-run-verified; token server-side, sandbox-only). Block A: edge /24s +172.30.2.0/24 (vr1-dc0) + 172.30.3.0/24 (vr1-dc1). Block B: operator chose "write the seeder" -> NEW +`netbox/d124-transit-seed.py` + `tests/d124-transit-seed/` (21/21, incl. a cross-file consistency check vs +dc-rack-mgmt) seeds role `transit` + 172.31.0.0/24 unscoped container; then `dc-rack-mgmt-import` placed +172.31.0.0/30 (transit, vr1-dc0) + 10.12.8.2/22 (rack, dns=vvr1-dc0) -- matches the D-124 tfvars. The full +D-124/edge-WAN IPAM is now in the apex. See `docs/changelog-20260717-netbox-apex-recovery-edge-wan-import.md`. **vcloud OS-UPDATE + REBOOT window (2026-07-16) -- IN FLIGHT; D-127 autostart policy recorded.** diff --git a/netbox/d124-transit-seed.py b/netbox/d124-transit-seed.py new file mode 100644 index 0000000..632dca4 --- /dev/null +++ b/netbox/d124-transit-seed.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Seed the D-124 transit role + the 172.31.0.0/24 transit supernet container. + +These are the PRECONDITIONS that netbox/dc-rack-mgmt-import.py is deliberately +die-if-absent on (it creates NEITHER the role NOR the container -- see its docstring). +D-124 (operator-ratified 2026-07-16) carves a DEDICATED transit supernet, NOT under +Cloud, mirroring the D-115 `edge` role's dedicated 172.30.0.0/16: + + 1. role `transit` -- the transit's OWN role (mirrors `edge`), NOT `infra`/`oob`. + 2. 172.31.0.0/24 -- the dedicated per-DC point-to-point transit supernet, as a + CONTAINER. UNSCOPED, exactly like edge's /16 container: each DC's per-DC + transit link is carved from it and scoped to that DC's site (dc-rack-mgmt-import + places vr1-dc0's link -> site vr1-dc0; a future dc1's link -> vr1-dc1). Keeping + the container unscoped is what lets it serve every DC. + +After this runs (--commit), dc-rack-mgmt-import finds role+container+site and can +place the per-DC transit link. This tool creates ONLY the role + the container -- +never a transit link prefix (that is dc-rack-mgmt-import's job) and never the site +(a precondition). + +DRY BY DEFAULT. Nothing is written without --commit. + +WRITING UPSTREAM IS GATED IN CODE (same standing rule as the sibling importers): +pointing this at anything that is not an obvious sandbox needs --yes-write-upstream +ON TOP of --commit. Feeding the production apex is an operator decision. + +Usage (sandbox, on office1-netbox): + sudo NETBOX_URL=http://localhost:8000 \ + NETBOX_TOKEN="$(cat /root/netbox-secrets/api.token)" \ + python3 d124-transit-seed.py # preview + ... --commit # apply +""" + +import argparse +import ipaddress +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +UA = "curl/8.5.0" # upstream 403s the default python UA -- see platform-traps.md + +# The transit role + container. Values MUST match netbox/dc-rack-mgmt-import.py's +# ROLE_SLUG + CONTAINER (D-124), or that importer's preconditions still fail. +ROLE = {"slug": "transit", "name": "Transit"} +CONTAINER = "172.31.0.0/24" +CONTAINER_DESC = ("Transit (v4) -- dedicated per-DC point-to-point transit supernet " + "(D-124, operator-pinned 2026-07-16); UNSCOPED container, each DC's link " + "scoped to its site. Precondition for dc-rack-mgmt-import.") + +# A sandbox is local, or the known Office1 sandbox address. Anything else is +# treated as production and requires the explicit upstream flag. +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"} + + +def die(msg: str): + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(2) + + +class NB: + def __init__(self, base, token): + self.base = base.rstrip("/") + self.token = token + + def _req(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(f"{self.base}/api/{path}", data=data, method=method, + headers={"Authorization": f"Token {self.token}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=45) as r: + return json.load(r) if r.status != 204 else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace")[:300] + if exc.code == 403 and "v1 token" in detail: + die("403 'Invalid v1 token' -- NetBox 4.6 wants the ASSEMBLED v2 token " + "nbt_., not the API's bare `token` field.") + if exc.code == 403: + die(f"403 on {path}. If curl works with this token, it is the upstream " + f"User-Agent filter, NOT the token (references/platform-traps.md).") + die(f"HTTP {exc.code} {method} {path}: {detail}") + + def one(self, path, **flt): + res = self._req("GET", f"{path}/?{urllib.parse.urlencode(flt)}&limit=1") + return res["results"][0] if res["results"] else None + + def create(self, path, payload): + return self._req("POST", f"{path}/", payload) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + 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", + help="Required (with --commit) to write to a NON-sandbox NetBox. " + "Feeding the production apex is an operator decision.") + args = ap.parse_args() + + url = os.environ.get("NETBOX_URL") + token = os.environ.get("NETBOX_TOKEN") + if not url or not token: + die("NETBOX_URL and NETBOX_TOKEN must be set.") + + ipaddress.ip_network(CONTAINER) # fail loud on a typo, before any write + + host = (urllib.parse.urlparse(url).hostname or url).lower() + is_sandbox = host in SANDBOX_HOSTS + + print(f"Target : {url} ({'SANDBOX' if is_sandbox else 'NOT a known sandbox'})") + if args.commit and not is_sandbox and not args.yes_write_upstream: + die(f"REFUSING to --commit to '{host}': it is not a known sandbox, so this is " + f"treated as the PRODUCTION apex. Feeding validated refinements upstream is " + f"an operator decision, not a side effect of running a seed. Re-run with " + f"--yes-write-upstream if that is genuinely what you intend.") + + print("\n*** DRY RUN -- nothing will be written. Re-run with --commit. ***" + if not args.commit else "\n*** COMMITTING. ***") + + nb = NB(url, token) + created = existing = 0 + + print("\nIPAM role:") + role = nb.one("ipam/roles", slug=ROLE["slug"]) + if role: + print(f" EXISTS role {ROLE['slug']} (id={role['id']})") + existing += 1 + elif not args.commit: + print(f" [dry-run] would CREATE role {ROLE['slug']} ({ROLE['name']})") + created += 1 + else: + role = nb.create("ipam/roles", {"name": ROLE["name"], "slug": ROLE["slug"]}) + print(f" CREATED role {ROLE['slug']} (id={role['id']})") + created += 1 + + print("\nTransit supernet container:") + if nb.one("ipam/prefixes", prefix=CONTAINER): + print(f" EXISTS {CONTAINER}") + existing += 1 + elif not args.commit: + print(f" [dry-run] would CREATE {CONTAINER:<16} role={ROLE['slug']:<7} container scope=- (unscoped)") + created += 1 + else: + if role is None: + die(f"role '{ROLE['slug']}' absent -- cannot place {CONTAINER} (seed the role first)") + o = nb.create("ipam/prefixes", {"prefix": CONTAINER, "role": role["id"], + "status": "container", "description": CONTAINER_DESC}) + print(f" CREATED {CONTAINER} (id={o['id']}) role={ROLE['slug']} container") + created += 1 + + verb = "would create" if not args.commit else "created" + print(f"\n{'='*66}\n{verb}: {created} already present: {existing}") + if not args.commit: + print("DRY RUN -- nothing was written. Re-run with --commit.") + else: + print("Next: run netbox/dc-rack-mgmt-import.py to place the per-DC transit link + rack IP.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/d124-transit-seed/run-tests.sh b/tests/d124-transit-seed/run-tests.sh new file mode 100644 index 0000000..952e13d --- /dev/null +++ b/tests/d124-transit-seed/run-tests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# tests/d124-transit-seed/run-tests.sh +# +# Harness for netbox/d124-transit-seed.py. OFFLINE -- touches no NetBox. +# STATIC greps that PIN the D-124 transit values + the guards every sandbox-loop +# writer must have (dry-by-default, upstream-write gated IN CODE, WAF-safe UA, +# fail-loud on missing env). PLUS a cross-file consistency check: the role slug + +# container this SEEDS must equal what dc-rack-mgmt-import.py REQUIRES, or that +# importer's die-if-absent preconditions would still fail after a seed. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../netbox" && pwd)/d124-transit-seed.py" +R="$(cd "$HERE/../../netbox" && pwd)/dc-rack-mgmt-import.py" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +command -v python3 >/dev/null 2>&1 || { echo "FAIL: python3 required"; exit 1; } + +python3 -c "import ast;ast.parse(open('$S').read())" 2>/dev/null && ok || bad "does not parse" +python3 "$S" --help >/dev/null 2>&1 && ok || bad "--help -> 0" + +# no env -> must fail loud, not guess a target +NETBOX_URL= NETBOX_TOKEN= python3 "$S" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "missing NETBOX_URL/TOKEN must fail" + +# DRY BY DEFAULT +grep -q '"--commit", action="store_true"' "$S" && ok || bad "no --commit flag -- must be dry by default" +grep -q 'DRY RUN -- nothing will be written' "$S" && ok || bad "does not announce its dry run" + +# UPSTREAM WRITE GATED IN CODE (not by discipline) +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" --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)" + +# WAF: UA-aware or every upstream call 403s (looks like auth failure) +grep -q 'User-Agent' "$S" && grep -q 'curl/8.5.0' "$S" && ok || bad "lost the WAF-safe User-Agent" + +# THE D-124 TRANSIT VALUES (operator-pinned) +grep -qF 'ROLE = {"slug": "transit"' "$S" && ok || bad "transit role slug changed" +grep -qF 'CONTAINER = "172.31.0.0/24"' "$S" && ok || bad "transit container is not 172.31.0.0/24 (D-124)" +grep -q '"status": "container"' "$S" && ok || bad "the supernet is not created as a container prefix" + +# CROSS-FILE CONSISTENCY -- what we SEED must equal what dc-rack-mgmt-import REQUIRES, +# or the seed is useless (that importer would still die on role/container absent). +grep -qF 'ROLE_SLUG = "transit"' "$R" && ok || bad "dc-rack-mgmt ROLE_SLUG != 'transit' -- seed would not satisfy it" +grep -qF 'CONTAINER = "172.31.0.0/24"' "$R" && ok || bad "dc-rack-mgmt CONTAINER != 172.31.0.0/24 -- seed would not satisfy it" + +# SCOPE: the container is UNSCOPED (shared across DCs) -- it must NOT bind a site. +# (The per-DC /30 is dc-rack-mgmt-import's job + IS site-scoped; this tool is not.) +grep -q 'scope_type' "$S" && bad "the container must be UNSCOPED (no scope_type) -- per-DC /30 scoping is dc-rack-mgmt's job" || ok + +# This tool seeds ONLY the role + container -- NOT a transit /30, NOT the site. +grep -qE 'create\("dcim/sites"' "$S" && bad "this tool CREATES a site -- the site is a precondition" || ok +grep -qE '/30|/31|transit.*/3[01]|--transit-cidr' "$S" && bad "this tool references a transit /30 -- that is dc-rack-mgmt's job, not the seeder's" || ok +grep -q 'create("ipam/roles"' "$S" && ok || bad "does not create the transit role (its job)" +grep -q 'create("ipam/prefixes"' "$S" && ok || bad "does not create the container prefix (its job)" + +# ASCII + LF +LC_ALL=C grep -qP '[^\x00-\x7F]' "$S" && bad "non-ASCII byte in script" || ok +grep -qU $'\r' "$S" && bad "CR byte (must be LF)" || ok + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "d124-transit-seed: $pass/$total PASS"; exit 0; fi +echo "d124-transit-seed: $fail/$total FAIL"; exit 1