diff --git a/docs/changelog-20260715-d120-load-and-iprange-tooling.md b/docs/changelog-20260715-d120-load-and-iprange-tooling.md new file mode 100644 index 0000000..82aa282 --- /dev/null +++ b/docs/changelog-20260715-d120-load-and-iprange-tooling.md @@ -0,0 +1,53 @@ +# 2026-07-15 -- C2 sub-task (ii): D-120 bands loaded + verified; sandbox loop extended to ip-ranges/ip-addresses + +## Context + +C2 sub-task (ii), per DOCFIX-195: load the D-120 compose-/24 band ranges + the two +static service IPs into office1-netbox (the VR1 IPAM apex) and VERIFY them. The logged +tooling gap: the sandbox loop (`prod-draft-dump.py` + `sandbox-fidelity-check.py`) did +not cover `ip-ranges`/`ip-addresses` at all, so those objects were invisible to the +fidelity check. This closes that gap and then does the load, verified. + +## What changed + +**Tooling (the "A" extension):** +- `netbox/prod-draft-dump.py` -- ENDPOINTS += `ipam/ip-ranges` and `ipam/ip-addresses`. + Ranges get a synthetic `range` key (`start-end`) since the checker keys on one field. + Verified live: the endpoints dump (0/0 before the load). +- `netbox/sandbox-fidelity-check.py` -- `KEY` += the two endpoints; `EXPECTED_NEW` += the + 3 D-120 ranges (keyed start-end) + the 2 service IPs (keyed address). They are pure + delta (the frozen v1 draft carries none), so both-bounds already gives endpoint/address + correctness -- a wrong endpoint is a different key (UNEXPECTED-EXTRA + EXPECTED-BUT-ABSENT). + Success banner now reads D-115/D-117/D-118/**D-120**. +- `tests/sandbox-fidelity-check/run-tests.sh` -- +T11 (a dropped D-120 range -> + EXPECTED-BUT-ABSENT) +T12 (a stray ip-address -> UNEXPECTED-EXTRA). 12 -> 14. + +**Importer:** +- `netbox/d120-compose-bands.py` -- stdlib, UA-aware, `SANDBOX_HOSTS`-guarded, + DRY-BY-DEFAULT (`--commit` to write, `--yes-write-upstream` for a non-sandbox target), + with a WHOLE-PLAN PREFLIGHT (parent `10.10.1.0/24` must exist; every endpoint in-parent; + start<=end) so a bad plan cannot half-write. Creates the 3 ranges + 2 IPs, idempotent. + Harness `tests/d120-compose-bands/` (16/16) pins the band values + the guards. + +**The load (executed 2026-07-15, operator-approved):** +Dry-run previewed 5 creates / 0 present; `--commit` against office1-netbox created: +- ip-ranges: `10.10.1.2-.49` (static), `.100-.200` (dynamic), `.201-.254` (node) +- ip-addresses: `10.10.1.10` (office1-netbox), `10.10.1.11` (office1-tailscale) +Re-dump -> ip-ranges 3, ip-addresses 2. Fidelity check -> **exit 0**: "upstream draft + +EXACTLY the planned delta (D-115/D-117/D-118/D-120)." + +## Result + +- **C2 sub-task (ii) CLOSED; C2 fully closed.** office1-netbox now holds the complete + validated VR1 dataset, verified by a fidelity check that covers every object class in it. +- **D-120 Step 6 CLOSED** -- the band layout is now recorded in NetBox, no apex write. +- Gauntlet ALL GREEN (60 harnesses); repo-lint 0 fail. +- Stage 2 close-out (C1..C5) is COMPLETE -> the branch->main merge point is reached + (the merge itself remains an operator-gated action). + +## Revert + +- `git rm netbox/d120-compose-bands.py tests/d120-compose-bands/run-tests.sh docs/changelog-20260715-d120-load-and-iprange-tooling.md` +- `git checkout HEAD -- netbox/prod-draft-dump.py netbox/sandbox-fidelity-check.py tests/sandbox-fidelity-check/run-tests.sh docs/session-ledger.md docs/dc-dc-deployment-workflow.md` +- The 5 NetBox objects (ip-ranges 1-3, ip-addresses 1-2 on office1-netbox) are additive; + delete them in the NetBox UI if the load must be undone. No existing object was edited. diff --git a/docs/dc-dc-deployment-workflow.md b/docs/dc-dc-deployment-workflow.md index c1d1d94..5934adf 100644 --- a/docs/dc-dc-deployment-workflow.md +++ b/docs/dc-dc-deployment-workflow.md @@ -138,7 +138,7 @@ | # | Item | State | |---|---|---| | C1 | Office1 LAN IPv6 deployed (`2602:f3e2:f01:100::1/64` + RA on the edge LAN). WAN v6 leg DEFERRED: v6 does not egress the lab. | **DONE 2026-07-14** -- live and PROVEN ON THE WIRE: address on the kernel, radvd advertising the prefix (`mode=unmanaged`), `voffice1` autoconfigured `...:5054:ff:fe6a:87e5` by SLAAC, edge->voffice1 GUA ping 0.0% loss. NOTE the D-113 AMENDMENT: the ADDRESS half is **not** REST-API-doable (`scripts/opnsense-set-interface-v6.sh`); only the RA half is. `docs/changelog-20260714-office1-lan-ipv6-executed.md` | -| C2 | `office1-netbox` (10.10.1.10) is the VR1 IPAM apex -- a working draft (DOCFIX-195) -- and holds the COMPLETE validated dataset: D-115 carve + six-plane roles + both DCs' prefixes/aggregates/RIRs, PLUS the D-120 additions -- `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). `netbox.baldurkeep.com` stays a READ-ONLY v1 reference draft; the write-back to it is DEFERRED to end-of-deployment (DOCFIX-195, operator ruling 2026-07-15) -- NOT a Stage 2 gate. | **OPEN** -- two sub-tasks: (i) re-run the HARDENED fidelity check on the shared carve/roles/DCs (the "faithful" verdict predates the 2026-07-14 hardening, per section 8.3); (ii) load the D-120 child ranges + service IPs into `office1-netbox` and verify them. **TOOLING GAP (logged DOCFIX-195):** the D-120 objects are `ip-ranges`/`ip-addresses`, object classes the current sandbox loop does NOT cover -- `prod-draft-dump.py` ENDPOINTS and `sandbox-fidelity-check.py` KEY/EXPECTED_NEW stop at rirs/roles/regions/sites/aggregates/prefixes -- so verifying them needs extending both tools (+ harnesses) OR a recorded manual read-back. | +| C2 | `office1-netbox` (10.10.1.10) is the VR1 IPAM apex -- a working draft (DOCFIX-195) -- and holds the COMPLETE validated dataset: D-115 carve + six-plane roles + both DCs' prefixes/aggregates/RIRs, PLUS the D-120 additions -- `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). `netbox.baldurkeep.com` stays a READ-ONLY v1 reference draft; the write-back to it is DEFERRED to end-of-deployment (DOCFIX-195, operator ruling 2026-07-15) -- NOT a Stage 2 gate. | **DONE 2026-07-15.** (i) The HARDENED fidelity check was re-run against office1-netbox -> exit 0 (faithful); it also surfaced + fixed a checker scope false-positive (`docs/changelog-20260715-fidelity-check-scope-correctness.md`). (ii) The tooling gap was CLOSED -- `prod-draft-dump.py` + `sandbox-fidelity-check.py` now cover `ip-ranges`/`ip-addresses` -- and the D-120 objects (3 bands + 2 service IPs) were loaded into office1-netbox via `netbox/d120-compose-bands.py --commit` and VERIFIED: re-dump + fidelity check -> exit 0, "EXACTLY the planned delta (D-115/D-117/D-118/D-120)." `docs/changelog-20260715-d120-load-and-iprange-tooling.md`. Gauntlet ALL GREEN (60). | | C3 | The `$DC` shell-selector collision resolved. | **DONE 2026-07-14 (D-119)** -- selectors are REGION-QUALIFIED (`vr0-dc0`/`vr1-dc0`/`vr1-dc1`) across `lib-net.sh`, `lib-hosts.sh`, `opentofu/`, the importer and every runbook call site. Bare `dcN` is REJECTED. The importer's DC->site map is now an IDENTITY, so the off-by-one is structurally impossible. Gap #19 CLOSED. | | C4 | The NetBox sandbox loop documented. | **DONE 2026-07-14** -- `docs/dc-dc-netbox-buildout-scope.md` section 8 (rationale + write-guard table + WAF trap) and `runbooks/dc-dc-phase1-office1-standup.md` Step 10b (operational sequence). | | C5 | This doc's Stage 2 row reconciled against reality (it has drifted three times). | **DONE 2026-07-14** | diff --git a/docs/session-ledger.md b/docs/session-ledger.md index 7665842..6f1bf6e 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -97,12 +97,12 @@ one. Method finding: MAAS won't edit a Deployed machine's interface (lift = redeploy = WIPE), so the address is set on the guest (netplan + curtin, over `lxc exec`) and MAAS-model drift is accepted (reconciles at next teardown/redeploy). Sweep + gauntlet done; `runbooks/dc-dc-office1-service-reip.md` -now records the measured method. **Still owed:** D-120 Step 6 -- register the /24 child ranges + the -two service IPs in `office1-netbox` (the VR1 IPAM apex, DOCFIX-195); rides with C2 (the only -C-item left). NO apex write -- the upstream write-back is DEFERRED to end-of-deployment. NOTE the -tooling gap logged under C2: these are `ip-ranges`/`ip-addresses`, object classes the sandbox dumper + -fidelity check do NOT cover, so verifying them needs a tool extension (+ harness) or a recorded manual -read-back. +now records the measured method. **D-120 Step 6 DONE 2026-07-15** -- the /24 child ranges + the two +service IPs are registered in `office1-netbox` (the VR1 IPAM apex) via `netbox/d120-compose-bands.py +--commit` and VERIFIED by the (now-extended) fidelity check -> exit 0. The `ip-ranges`/`ip-addresses` +tooling gap is CLOSED (dumper + checker now cover both classes). NO apex write -- the upstream +write-back stays DEFERRED to end-of-deployment. This closed the last piece of close-out C2. See +`docs/changelog-20260715-d120-load-and-iprange-tooling.md`. The authoritative per-stage tracker is `docs/dc-dc-deployment-workflow.md` (stage table + tooling @@ -206,9 +206,15 @@ (`10.10.0.0/16`, `172.30.0.0/16`); fixed to assert scope == INTENDED (now also catches a WRONG-TARGET binding -- the D-117 near-miss), harness 10->12, gauntlet ALL GREEN (58). SEC-009 gap closed: the sandbox token was never consolidated; it now lives at `~/vr1-office1-creds/vr1-netbox-sandbox.env`. - See `docs/changelog-20260715-fidelity-check-scope-correctness.md`. **C2 sub-task (ii) STILL OPEN** -- - load + verify the D-120 `ip-ranges`/`ip-addresses` (the logged tooling gap: dumper+checker don't yet - cover those object classes). + See `docs/changelog-20260715-fidelity-check-scope-correctness.md`. + **C2 sub-task (ii) DONE 2026-07-15 -> C2 FULLY CLOSED -> STAGE 2 CLOSE-OUT COMPLETE (C1..C5 all done).** + The tooling gap was closed (`prod-draft-dump.py` + `sandbox-fidelity-check.py` now cover + `ip-ranges`/`ip-addresses`; harnesses +T11/T12 and a new `netbox/d120-compose-bands.py` +16/16 + harness), and the D-120 objects (3 bands + 2 service IPs) were loaded into office1-netbox + (`--commit`, operator-approved) and VERIFIED: re-dump + fidelity check -> exit 0, "EXACTLY the planned + delta (D-115/D-117/D-118/D-120)." Gauntlet ALL GREEN (60). D-120 Step 6 CLOSED (no apex write). See + `docs/changelog-20260715-d120-load-and-iprange-tooling.md`. **NEXT: the branch->`main` merge** (Stage 2 + close is the merge point, PINNED) -- an operator-gated action, present it, do not run it unasked. 3. **`pynetbox` -- CLOSED 2026-07-13.** Installed (7.0.0) on `office1-netbox`, which is where the imports actually run. The stdlib tools (`prod-draft-dump`/`sandbox-seed`/`sandbox-fidelity-check`) deliberately need no dependency at all. diff --git a/netbox/d120-compose-bands.py b/netbox/d120-compose-bands.py new file mode 100755 index 0000000..ece353b --- /dev/null +++ b/netbox/d120-compose-bands.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Register the D-120 compose-/24 band ranges + the static service IP assignments. + +D-120 (ADOPTED 2026-07-15) ratified a functional band layout for every MAAS-managed +compose /24, and D-120 Step 6 owes NetBox the record of it. On Office1's compose net +10.10.1.0/24 (lxdbr0) this creates: + + ip-ranges (the child bands D-120 names for NetBox: static / dynamic / node): + 10.10.1.2/24 - 10.10.1.49/24 static site-services (.2-.49) + 10.10.1.100/24 - 10.10.1.200/24 MAAS dynamic (.100-.200) + 10.10.1.201/24 - 10.10.1.254/24 deployed nodes (.201-.254) + ip-addresses (the two re-IP'd static services): + 10.10.1.10/24 office1-netbox (re-IP from .201, D-120) + 10.10.1.11/24 office1-tailscale (re-IP from .202, D-120) + +(D-120's layout also names .1 gateway and .50-.99 reserved, but its NetBox instruction +is explicitly "child ranges (static/dynamic/node)" -- so those three, not the others.) + +DRY BY DEFAULT -- nothing is written without --commit. + +WRITING UPSTREAM IS GATED IN CODE. The sim never writes the production apex casually: +SANDBOX_HOSTS below is the allowlist; a --commit at any other host REFUSES without +--yes-write-upstream. WHOLE-PLAN PREFLIGHT: the parent compose net must exist and every +range/IP must fall inside it, checked BEFORE any create, so a bad plan cannot half-write. + +Usage (on office1-netbox / through a tunnel, with the sandbox token): + NETBOX_URL=http://10.10.1.10:8000 NETBOX_TOKEN= python3 netbox/d120-compose-bands.py + ... same, add --commit, to write. +""" +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 + +# A sandbox is local, or the known Office1 sandbox address. Anything else is treated as +# the production apex and requires the explicit upstream flag. +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"} + +PARENT = "10.10.1.0/24" # the compose net these bands/IPs live within (must pre-exist) + +# start/end carry the /24 mask, per NetBox convention (netbox/ipv4-prefixes-import.py). +IP_RANGES = [ + ("10.10.1.2/24", "10.10.1.49/24", "active", + "D-120 static site-services band (.2-.49) -- MAAS static-assign, outside the dynamic range"), + ("10.10.1.100/24", "10.10.1.200/24", "active", + "D-120 MAAS dynamic band (.100-.200) -- enlistment/commissioning/PXE/DHCP"), + ("10.10.1.201/24", "10.10.1.254/24", "active", + "D-120 deployed-node band (.201-.254) -- MAAS auto-assign for compute/OpenStack nodes"), +] +IP_ADDRESSES = [ + ("10.10.1.10/24", "active", "office1-netbox", + "office1-netbox service (D-120 static band; re-IP from .201)"), + ("10.10.1.11/24", "active", "office1-tailscale", + "office1-tailscale subnet router (D-120 static band; re-IP from .202)"), +] + + +def die(msg: str): + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(2) + + +class NB: + """Stdlib NetBox client -- same shape as the other sandbox-loop tools; UA-aware so + it is not 403'd by the upstream User-Agent filter.""" + 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.") + 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.") + + 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}': not a known sandbox, so treated as the " + f"PRODUCTION apex. Re-run with --yes-write-upstream if that is intended.") + + print("\n*** DRY RUN -- nothing will be written. Re-run with --commit. ***" + if not args.commit else "\n*** COMMITTING. ***") + + nb = NB(url, token) + + # WHOLE-PLAN PREFLIGHT -- fail loud BEFORE any create so a bad plan cannot half-write. + net = ipaddress.ip_network(PARENT) + for s, e, _st, _d in IP_RANGES: + for a in (s, e): + if ipaddress.ip_interface(a).ip not in net: + die(f"range endpoint {a} is outside the parent {PARENT}") + if ipaddress.ip_interface(s).ip > ipaddress.ip_interface(e).ip: + die(f"range {s} - {e} has start > end") + for a, _st, _dns, _d in IP_ADDRESSES: + if ipaddress.ip_interface(a).ip not in net: + die(f"address {a} is outside the parent {PARENT}") + if nb.one("ipam/prefixes", prefix=PARENT) is None: + die(f"parent prefix {PARENT} absent in NetBox -- seed the D-115 carve first " + f"(netbox/d115-office-carve.py). Refusing to place bands in an unallocated /24.") + + created = existing = 0 + + print("\nIP ranges:") + for start, end, status, desc in IP_RANGES: + if nb.one("ipam/ip-ranges", start_address=start, end_address=end): + print(f" EXISTS {start} - {end}") + existing += 1 + continue + if not args.commit: + print(f" [dry-run] would CREATE range {start} - {end} ({status})") + created += 1 + continue + o = nb.create("ipam/ip-ranges", {"start_address": start, "end_address": end, + "status": status, "description": desc}) + print(f" CREATED range {start} - {end} (id={o['id']})") + created += 1 + + print("\nIP addresses:") + for addr, status, dns, desc in IP_ADDRESSES: + if nb.one("ipam/ip-addresses", address=addr): + print(f" EXISTS {addr}") + existing += 1 + continue + if not args.commit: + print(f" [dry-run] would CREATE address {addr} dns={dns}") + created += 1 + continue + o = nb.create("ipam/ip-addresses", {"address": addr, "status": status, + "dns_name": dns, "description": desc}) + print(f" CREATED address {addr} (id={o['id']})") + 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.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/netbox/prod-draft-dump.py b/netbox/prod-draft-dump.py index dc2830f..af7793a 100755 --- a/netbox/prod-draft-dump.py +++ b/netbox/prod-draft-dump.py @@ -54,6 +54,12 @@ ("ipam/aggregates", ["prefix", "rir", "description"]), ("ipam/prefixes", ["prefix", "status", "role", "scope_type", "scope_id", "description", "is_pool", "mark_utilized"]), + # D-120: the compose-/24 band ranges + the static service IP assignments. These + # object classes were previously NOT dumped, so the sandbox loop could not verify + # the D-120 load at all (the "tooling gap" logged in the session ledger). Ranges + # get a synthetic "range" key (start-end) below, since KEY is one field. + ("ipam/ip-ranges", ["start_address", "end_address", "status", "role", "description"]), + ("ipam/ip-addresses", ["address", "status", "role", "dns_name", "description"]), ] @@ -145,6 +151,10 @@ elif sid is not None and st == "dcim.region": rec["scope_region"] = region_id_to_slug.get(sid) rec.pop("scope_id", None) + if path == "ipam/ip-ranges": + # a range is identified by BOTH endpoints; give the fidelity check a + # single stable key (start-end) the way prefixes key on "prefix". + rec["range"] = f'{rec.get("start_address")}-{rec.get("end_address")}' slimmed.append(rec) draft[path] = slimmed print(f" {path:<18} {len(slimmed)}") diff --git a/netbox/sandbox-fidelity-check.py b/netbox/sandbox-fidelity-check.py index 028e5a4..4bd4261 100755 --- a/netbox/sandbox-fidelity-check.py +++ b/netbox/sandbox-fidelity-check.py @@ -39,7 +39,10 @@ sb = json.load(open(args.sandbox)) KEY={"ipam/rirs":"slug","ipam/roles":"slug","dcim/regions":"slug","dcim/sites":"slug", - "ipam/aggregates":"prefix","ipam/prefixes":"prefix"} + "ipam/aggregates":"prefix","ipam/prefixes":"prefix", + # D-120: the compose-/24 band ranges + the static service IPs. Ranges key on the + # dumper's synthetic "range" (start-end); IPs on "address". + "ipam/ip-ranges":"range","ipam/ip-addresses":"address"} # The FULL planned delta the sandbox is allowed to have over the upstream draft. # Anything outside this is a tooling bug (a silent drop, or a stray write). # D-115 the office carve (site vr1-off1, edge role, 8 prefixes) @@ -99,6 +102,15 @@ "ipam/aggregates": {"2602:f3e2::/36", "23.157.124.0/24", "10.0.0.0/8", "172.16.0.0/12", "fd50:840e:74e2::/48"}, "ipam/prefixes": set(EXPECTED_PREFIX_SCOPE), + # D-120 (must match netbox/d120-compose-bands.py). Ranges keyed start-end (the + # dumper's synthetic "range"); IPs by address. Both carry the /24 mask. These are + # pure delta -- the frozen v1 draft carries no ip-ranges/ip-addresses -- so + # both-bounds already gives endpoint/address correctness (a wrong endpoint is a + # different key: it shows as UNEXPECTED-EXTRA + EXPECTED-BUT-ABSENT). + "ipam/ip-ranges": {"10.10.1.2/24-10.10.1.49/24", + "10.10.1.100/24-10.10.1.200/24", + "10.10.1.201/24-10.10.1.254/24"}, + "ipam/ip-addresses": {"10.10.1.10/24", "10.10.1.11/24"}, } bad=0 for ep,k in KEY.items(): @@ -169,7 +181,7 @@ print(" field-level: IDENTICAL on every shared object") print("\n" + "=" * 70) if bad == 0: - print("SANDBOX = upstream draft + EXACTLY the planned delta (D-115/D-117/D-118). Nothing lost, nothing stray.") + print("SANDBOX = upstream draft + EXACTLY the planned delta (D-115/D-117/D-118/D-120). Nothing lost, nothing stray.") sys.exit(0) print(f"!! {bad} fidelity problem(s) -- the seeder is dropping or mangling data") sys.exit(1) diff --git a/tests/d120-compose-bands/run-tests.sh b/tests/d120-compose-bands/run-tests.sh new file mode 100755 index 0000000..b63bbf6 --- /dev/null +++ b/tests/d120-compose-bands/run-tests.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# tests/d120-compose-bands/run-tests.sh +# +# Harness for netbox/d120-compose-bands.py. OFFLINE -- touches no NetBox. +# +# Pins the D-120 band VALUES (a changed endpoint silently re-bands the /24), and the +# guard behaviour every sandbox-loop writer must have: dry by default, upstream-write +# gated IN CODE, and a whole-plan preflight so a bad plan cannot half-write. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../netbox" && pwd)/d120-compose-bands.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" +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)" + +# WHOLE-PLAN PREFLIGHT -- must validate the plan BEFORE any create (no half-writes) +grep -q "outside the parent" "$S" && ok || bad "lost the in-parent preflight" +grep -q "parent prefix .* absent" "$S" && ok || bad "lost the parent-exists preflight" + +# THE D-120 VALUES -- the 3 child ranges D-120 names for NetBox (static/dynamic/node) +for pair in \ + '"10.10.1.2/24", "10.10.1.49/24"' \ + '"10.10.1.100/24", "10.10.1.200/24"' \ + '"10.10.1.201/24", "10.10.1.254/24"'; do + grep -qF "$pair" "$S" && ok || bad "D-120 range [$pair] is GONE / changed" +done + +# The two re-IP'd service addresses (D-120: .201->.10, .202->.11) and their dns_names +grep -qF '"10.10.1.10/24"' "$S" && grep -q 'office1-netbox' "$S" && ok || bad "office1-netbox 10.10.1.10 assignment is gone" +grep -qF '"10.10.1.11/24"' "$S" && grep -q 'office1-tailscale' "$S" && ok || bad "office1-tailscale 10.10.1.11 assignment is gone" + +# The parent compose net must be the D-115 /24, not something invented +grep -qF 'PARENT = "10.10.1.0/24"' "$S" && ok || bad "parent compose net is not 10.10.1.0/24" + +# The node band must NOT still say .201/.202 for the services (the pre-re-IP snapshot) +grep -qE '"10\.10\.1\.20[12]/24", "active", "office1' "$S" && bad "a service is still addressed .201/.202 (pre-D-120 re-IP)" || ok + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "d120-compose-bands: $pass/$total PASS"; exit 0; fi +echo "d120-compose-bands: $fail/$total FAIL"; exit 1 diff --git a/tests/sandbox-fidelity-check/run-tests.sh b/tests/sandbox-fidelity-check/run-tests.sh index 0c8e2f7..92bb14f 100755 --- a/tests/sandbox-fidelity-check/run-tests.sh +++ b/tests/sandbox-fidelity-check/run-tests.sh @@ -155,6 +155,35 @@ grep -q "!= intended" "$TMP/out" && no "T10 an intentionally-unscoped role container was wrongly flagged (false positive is back)" \ || ok "T10 an intentionally-unscoped role container (10.10.0.0/16) is NOT flagged" +# T11: a D-120 ip-range DROPPED from an otherwise-complete sandbox -> EXPECTED-BUT-ABSENT. +# Proves the new ip-ranges endpoint is wired into the both-bounds check (the D-120 +# "tooling gap" this closes: those objects used to be invisible to the checker). +python3 - "$TMP" <<'PY' +import json, sys +tmp = sys.argv[1] +sb = json.load(open(f"{tmp}/s1.json")) +sb["ipam/ip-ranges"] = [r for r in sb["ipam/ip-ranges"] if r["range"] != "10.10.1.2/24-10.10.1.49/24"] +json.dump(sb, open(f"{tmp}/s11.json", "w")) +PY +python3 "$CHECK" --upstream "$TMP/u1.json" --sandbox "$TMP/s11.json" >"$TMP/out" 2>&1; rc=$? +[ "$rc" = 1 ] && grep -q "EXPECTED-BUT-ABSENT" "$TMP/out" && grep -q "10.10.1.2/24-10.10.1.49/24" "$TMP/out" \ + && ok "T11 a dropped D-120 ip-range -> EXPECTED-BUT-ABSENT, exit 1" \ + || { no "T11 a missing D-120 range should fail (rc=$rc)"; grep -i "range\|absent" "$TMP/out" | sed 's/^/ /'; } + +# T12: a STRAY ip-address not in EXPECTED_NEW -> UNEXPECTED EXTRA. Proves the +# ip-addresses endpoint is likewise covered (a stray write cannot hide). +python3 - "$TMP" <<'PY' +import json, sys +tmp = sys.argv[1] +sb = json.load(open(f"{tmp}/s1.json")) +sb["ipam/ip-addresses"].append({"address": "10.10.1.99/24"}) +json.dump(sb, open(f"{tmp}/s12.json", "w")) +PY +python3 "$CHECK" --upstream "$TMP/u1.json" --sandbox "$TMP/s12.json" >"$TMP/out" 2>&1; rc=$? +[ "$rc" = 1 ] && grep -q "UNEXPECTED EXTRA" "$TMP/out" && grep -q "10.10.1.99/24" "$TMP/out" \ + && ok "T12 a stray ip-address -> UNEXPECTED EXTRA, exit 1" \ + || { no "T12 a stray ip-address should fail (rc=$rc)"; grep -i "address\|extra" "$TMP/out" | sed 's/^/ /'; } + # T8: read-only -- no write verbs anywhere in the checker grep -qE '\.(create|update|delete|save)\(' "$CHECK" \ && no "T8 the checker contains a WRITE verb -- it must be read-only" \