diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bae951e..6289938 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3115,8 +3115,11 @@ API before the options were put: prefixes `['container','active','reserved','deprecated']`, ip-addresses `['active','reserved','deprecated','dhcp','slaac']`. **STEP 6 IS THEREFORE:** (1) CREATE 26 GUA VIP ip-addresses (`f02:20::50-::62` metal-admin, - `f02:21::50-::62` metal-internal) `status=active`; (2) set `status=deprecated` on the 26 ULA - VIP addresses; (3) set `status=deprecated` on the 9 ULA prefixes. **CREATE FIRST, VERIFY, + `f02:21::50-::62` metal-internal) **`status=reserved`** -- CORRECTED BY MEASUREMENT + (GA-R1 C2): this first read `active`, which was an inference; the live ULA VIP records are + `reserved` and `netbox/dc-plane-apex-import.py:186,200` also creates addresses `reserved`; + (2) set `status=deprecated` on the 26 ULA VIP addresses; (3) set `status=deprecated` on the + 9 ULA prefixes. **CREATE FIRST, VERIFY, THEN DEPRECATE** -- reversed, there would be an interval in which the apex marks a live VIP's only record unusable. **THE COST THE ORDERING RULING FLAGGED IS WITHDRAWN.** That entry called step 6 "the largest @@ -3128,6 +3131,41 @@ prevent an allocation, it signals one should not be made. The functional protection against handing out an in-use GUA address comes from the 26 GUA records EXISTING, not from the deprecation. + **>>> THE STEP-6 TOOL IS BUILT AND REVIEWED; THE APEX HALF IS READY TO RUN AND HAS NOT + BEEN RUN. <<<** `netbox/d139-step6-vip-rehome.py` + `tests/d139-step6-vip-rehome/` + (20 cases). **Dry run against the live apex: `CREATE 26 | ALREADY 0 | DEPRECATE-ADDR 26 | + DEPRECATE-PFX 9`, and the 26 CREATE targets diff EXACTLY against the 26 GUA VIP legs in + `overlays/vr1-dc0-vips.yaml`** -- every address written is one the deploy configures. + **NOTHING HAS BEEN WRITTEN; `--commit` has not been run.** Gates: harness 20/20, + gauntlet manifest 97 -> 98 recorded deliberately, repo-lint 0 fail. + **AN ADVERSARIAL REVIEW RETURNED "FIX FIRST" AND WAS RIGHT ON ALL FOUR COUNTS** + (`docs/audit/d139-step6-tool-review-20260802.txt`). The mapping logic was correct; the + gaps were PRECONDITIONS and COVERAGE. **DEF-1 (CRITICAL): `--dc vr1-dc1` would + ORPHAN-CREATE** -- targets were computed arithmetically and never checked to EXIST; + MEASURED, dc1's GUA carve is incomplete (four provider-public rows only), so dc1 planned + 26 creates into non-existent prefixes then deprecated its only authoritative rows at + rc=0. **dc0 hid it because all sixteen of its targets happen to exist.** **DEF-2 + (CRITICAL): the apex-IDENTITY guard was absent** -- it lives in the carve tool's `main()`, + and importing a module never runs its `main()`, so subclassing its client inherited the + TRANSPORT and left the SAFETY POSTURE behind; the v1 reference would have accepted + writes. **DEF-3 (HIGH): silent under-count** -- one missing prefix row gave 13/13/8 at + exit 0. **DEF-4 (HIGH): `main()` had ZERO coverage** -- the reviewer hoisted deprecate + above create and the suite still read ALL PASS. **ALL FOUR FIXED AND VERIFIED LIVE:** dc1 + now REFUSES, dc0 unchanged at 26/26/9, and T18 goes RED under the reviewer's exact + ordering mutation. **My first DEF-3 fix was itself wrong and running it caught that** -- + it scanned the whole retired `/48` and flagged dc1's 26 VIPs while planning dc0; the + `/48` is SHARED (dc0 `:22x`, dc1 `:32x`). + **TWO OF MY OWN ASSERTIONS COULD NOT FAIL and the review killed both:** T13 asserted the + ABSENCE of a string, so a traceback satisfied it -- it passed against a tool that did not + parse; T14 grepped ONE file, so a delete added to the IMPORTED module left it green. + **>>> STEP 6 IS NOT COMPLETE WHEN THIS TOOL FINISHES -- THE MAAS HALF IS A MEASURED, + UNDECIDED GAP. <<<** D-139's list says retire the ULA rows "in the apex AND in MAAS"; the + tool is apex-only. MEASURED on the dc0 region: MAAS holds BOTH families -- five ULA + `/64`s (`:220`, `:221`, `:230`, `:240`, `:250`) beside six GUA. Four are EMPTY; + **`fd50:840e:74e2:220::/64` (metal-admin) still holds 2 allocated entries**, so it cannot + be a blind cleanup. **MAAS has no `deprecated` status for a subnet** -- the choice is + delete or leave -- so this is a SEPARATE operator decision and is NOT covered by the + "Deprecate both, delete nothing" ruling, which was put and answered about the APEX. - Project: Omega Cloud, VR1 DC-DC rehearsal -- a two-DC + Office1-headend virtual rehearsal on KVM (vcloud host), rehearsing the future bare-metal Roosevelt deployment (D-100, `docs/design-decisions.md:1946`). diff --git a/docs/audit/d139-step6-tool-review-20260802.txt b/docs/audit/d139-step6-tool-review-20260802.txt new file mode 100644 index 0000000..0daac8f --- /dev/null +++ b/docs/audit/d139-step6-tool-review-20260802.txt @@ -0,0 +1,440 @@ +ADVERSARIAL REVIEW -- D-139 STEP 6 TOOL +======================================= +Date : 2026-08-02 +Reviewer : adversarial review pass (subagent), jumphost vcloud +Targets : netbox/d139-step6-vip-rehome.py (untracked, written 2026-08-02) + tests/d139-step6-vip-rehome/run-tests.sh (untracked, written 2026-08-02) +Governing : docs/design-decisions.md + - "ORDERING RULING 2026-08-02 -- D-139 step 6 executes IN FULL before the + Stage-5 deploy" -- operator: "Full step 6 first, then deploy" + - "AMENDMENT 2026-08-02 -- D-139 step 6: 'retire' means DEPRECATE, and + nothing is deleted" -- operator: "Deprecate both, delete nothing" + - "CORRECTION NOTE 2026-08-01 -- D-139: the EXECUTION list was defective" +Apex : http://10.10.1.10:8000 (office1-netbox, the WORKING VR1 apex, DOCFIX-195) + +VERDICT: FIX FIRST + Do NOT run --commit as the tool stands. Two MEASURED defects are reachable without any + operator error beyond choosing the other valid value of a required flag, and the harness + does not test the one property both operator rulings name as load-bearing. + The tool's core mapping logic is CORRECT and its dry run reproduces the ruling's + measured 26 / 26 / 9 exactly. The defects are missing PRECONDITIONS and missing + COVERAGE, not a wrong carve. + +METHOD AND HONESTY BOUNDARY + MEASURED = observed from a command run this session, output reproduced below. + REASONED = derived from reading the source; explicitly labelled where used. + Nothing in the DEFECTS section is REASONED-only; every one carries a run. + --commit was NEVER executed. All live apex access was read-only (GET). + Mutation experiments were run against COPIES under + /home/jessea123/.claude/jobs/015c103e/tmp/mut/ . No repo file was edited at any point. + + +------------------------------------------------------------------------------- +BASELINE -- what the tool does today, MEASURED +------------------------------------------------------------------------------- + + $ . ~/vr1-office1-creds/vr1-netbox-sandbox.env + $ python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0 (DRY RUN, rc=0) + + D-139 STEP 6 -- vr1-dc0 -- apex http://10.10.1.10:8000 + CREATE 26 | ALREADY 0 | DEPRECATE-ADDR 26 | DEPRECATE-PFX 9 + + The 26 creates are 2602:f3e2:f02:20::50-::62 (metal-admin) and + 2602:f3e2:f02:21::50-::62 (metal-internal), status=reserved, descriptions verbatim. + This matches the ORDERING RULING's measured state exactly. Host octets are preserved, + the ULA->GUA hextet mapping is right, and the /60 parents are deprecated but hold no + addresses. The offline harness reports PASS=14 FAIL=0. + + Independent read-only enumeration of the apex (152 prefixes, 160 addresses, 27 ranges) + confirms, MEASURED: + - 52 ULA v6 addresses exist: 26 in dc0 (fd50:...:220,:221), 26 in dc1 (:320,:321). + - All 26 dc0 ULA VIP records carry status=reserved. VIP_STATUS="reserved" is correct. + - Zero ip-ranges inside the retired ULA /48. Zero duplicate prefix CIDRs apex-wide. + - No provider-public ULA /64 exists, so the ambiguity REFUSE (:10 vs :11) is not + reachable on this apex today. It is a live-safe design, not a live-blocking one. + + CHECKED AND CLEAN (a gap I suspected and disproved): + overlays/vr1-dc0-vips.yaml declares 39 GUA VIP legs -- f02:11 x13, f02:20 x13, + f02:21 x13 -- and ZERO fd50: legs. Step 6 only creates 26. The missing 13 are NOT a + gap: 2602:f3e2:f02:11::50-::62 ALREADY EXIST in the apex as ip-address records + ("VIP provider-public v6 (vr1-dc0) -- octet mirror ::NN"). So 13 existing + 26 + created = the full 39, and after step 6 the apex records every VIP the overlay + declares. No action needed. + + +------------------------------------------------------------------------------- +DEFECTS, RANKED BY CONSEQUENCE +------------------------------------------------------------------------------- + +DEF-1 [CRITICAL -- defect MEASURED, consequence REASONED] + --dc vr1-dc1 creates 26 ORPHAN addresses and deprecates dc1's only valid records. + No precondition check that step 1 ran for the DC being operated on. + + --dc accepts choices=sorted(C.DC_GUA), i.e. BOTH vr1-dc0 and vr1-dc1 + (d139-step6-vip-rehome.py:152). vr1-dc1 is one keystroke from vr1-dc0. + + MEASURED, the apex's vr1-dc1 GUA carve is INCOMPLETE -- only 4 rows exist under + 2602:f3e2:f03::/48, all provider-public: + + 2602:f3e2:f03::/48 vr1-dc1 dc (container) + 2602:f3e2:f03:10::/60 vr1-dc1 provider-public + 2602:f3e2:f03:10::/64 vr1-dc1 provider-public + 2602:f3e2:f03:11::/64 vr1-dc1 provider-public + + There is NO 2602:f3e2:f03:20::/64 and NO 2602:f3e2:f03:21::/64. + + MEASURED, the tool nevertheless plans (dry run, rc=0, no warning of any kind): + + D-139 STEP 6 -- vr1-dc1 -- apex http://10.10.1.10:8000 + CREATE 26 | ALREADY 0 | DEPRECATE-ADDR 26 | DEPRECATE-PFX 9 + ... 2602:f3e2:f03:20::50/64 .. :20::62, 2602:f3e2:f03:21::50 .. :21::62 + DEPRECATE prefixes: fd50:840e:74e2:320::/60, :320::/64, :321::/64, + :330::/60, :330::/64, :340::/60, :340::/64, + :350::/60, :350::/64 + + With --commit this would write 26 ip-addresses into /64s that DO NOT EXIST as prefix + rows, then mark dc1's 26 authoritative ULA addresses and 9 ULA prefixes deprecated, + and exit 0. dc1's VIPs would then be recorded ONLY as orphans with no containing + prefix, while every row that currently describes them reads unusable. + + This is exactly the orphaning hazard DEFECT 3 of the 2026-08-01 CORRECTION NOTE + names, and exactly what the ordering ruling exists to prevent -- reached not by a + race or a partial failure but by the tool's own documented interface. + + Note the asymmetry that makes this easy to miss: gua_net_for_role() computes the + target /64 ARITHMETICALLY from C.DC_GUA and C.CARVE. It never asks the apex whether + that /64 exists. For vr1-dc0 all 16 target prefixes happen to exist (step 1 applied), + so dc0's dry run looks perfect and hides the missing check entirely. + + Compare the imported tool: d139-gua-carve.py checks its preconditions before writing + (exactly one site with the slug, line 177-180; every CARVE role exists in the apex, + line 183-186, "refusing to half-carve a DC"). d139-step6-vip-rehome.py checks none. + + FIX (suggested, operator's call): before planning, require that every GUA /64 the plan + targets EXISTS as a prefix row scoped to --dc, and die() if not, naming step 1. + Optionally also restrict --dc to vr1-dc0 for this window. + + +DEF-2 [CRITICAL -- defect MEASURED, consequence REASONED] + The SANDBOX_HOSTS apex-identity guard present in the imported tool is ABSENT from + the new tool. It will write to whatever NETBOX_URL names. + + d139-gua-carve.py carries the guard: + :25 SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.10"} + :161 if host not in SANDBOX_HOSTS: + :162 die("'%s' is not the VR1 working apex. office1-netbox (10.10.1.10) takes ALL + VR1 reads and writes; netbox.baldurkeep.com is the v1 REFERENCE and stays + untouched (DOCFIX-195)." % host) + + d139-step6-vip-rehome.py: grep -c SANDBOX_HOSTS -> 0. No urlsplit, no hostname check. + The guard lives inside d139-gua-carve.py's main(), which the import never executes, so + subclassing C.NB inherits the transport but NOT the safety posture. + + MEASURED differential against a non-sandbox host (127.0.0.9:1, unreachable, dummy + token -- nothing real was contacted): + + carve: REFUSE: '127.0.0.9' is not the VR1 working apex. ... (DOCFIX-195) rc=2 + step6: REFUSE: apex unreachable or rejecting: GET /ipam/prefixes/?limit=500 + -> rc=2 + + Both exit 2 here only because the host is unreachable. The carve tool refused on + IDENTITY, before any network call; the step-6 tool refused on REACHABILITY. Against a + REACHABLE non-sandbox apex -- netbox.baldurkeep.com, the v1 REFERENCE that DOCFIX-195 + says stays untouched -- the step-6 tool would proceed to plan and, with --commit, + write. One wrong `.` of an env file is the whole distance. + + This defect is a REGRESSION against the imported module's own stated posture, which is + the strongest argument for fixing it: the reviewer of step 1 already decided this + guard was required for a WRITE tool against this apex. + + FIX: lift the same guard (reuse C.SANDBOX_HOSTS, do not retype the set). + + +DEF-3 [HIGH, MEASURED] SILENT UNDER-COUNT: a missing or mis-scoped ULA /64 prefix row + silently halves the work and the run still exits 0. + + dep_addrs and creates are derived ONLY from addresses contained in a /64 prefix ROW + that is scoped to --dc and inside the retired ULA /48 (plan(), lines 102-141). The + addresses themselves are never counted independently, and no expected total is + asserted anywhere. + + MEASURED offline against the pristine repo file, full 26-address fixture, with only + the fd50:840e:74e2:221::/64 PREFIX row removed (its 13 addresses still present): + + D1 the :221::/64 prefix ROW is absent + -> CREATE=13 DEPA=13 DEPP=8 ALREADY=0 -- no complaint, plan() returns + D1b the :221::/64 row's scope is blank/name-only instead of the slug + -> CREATE=13 DEPA=13 DEPP=8 ALREADY=0 -- same + + Thirteen live VIPs would be neither created nor deprecated, and the run would report + success. D1b is not hypothetical shape-wise: scope_slug() itself exists because the + live API returns scope.slug while repo dumps normalise scope.name, and its own + docstring records that matching on name alone "found ZERO live". + + This is the same class as CORRECTION NOTE DEFECT 2 -- dc-node-v6-carve.py carving FOUR + FEWER PLANES PER NODE and EXITING CLEAN -- and the same class as the MEMORY entry + "instrument currency before negatives". The carve tool guards its half of this + (build_retire(): "the apex returned ZERO prefixes scoped to %s ... refusing to call + every plane 'missing' on that evidence"). The step-6 tool guards only the total-zero + case (`if not ula_pfx`), not the partial case, which is the one that actually bites. + + FIX: assert a floor. The ruling records 26 / 26 / 9 as MEASURED; make the tool require + it (e.g. --expect-creates / --expect-deprecate-addrs / --expect-deprecate-prefixes, + or cross-check dep_addrs against the count of ALL v6 addresses inside the retired /48 + that are NOT in another DC's scoped prefixes). Any tool that can do less than the + ruling specifies and still exit 0 is not gated. + + +DEF-4 [HIGH, MEASURED] The HARNESS does not test main() at all -- so the CREATE -> + verify -> DEPRECATE ORDER, the one property both rulings name, is UNCOVERED. + + All of T1-T12 call plan(). Nothing calls or inspects main(). The harness header states + "the tool's write path is CREATE -> verify -> DEPRECATE ... plan() is what decides all + three sets, so it is where the risk lives" -- that sentence is the bug. plan() decides + the SETS; main() decides the ORDER, and ORDER is what the ruling constrains: + "CREATE FIRST, VERIFY, THEN DEPRECATE -- reversed, there would be an interval in which + the apex marks a live VIP's only record unusable." + + MUTATION M1b, MEASURED. Into a COPY of the tool, immediately before the "PHASE 1: + CREATE" block, inserted: + + for _x in dep_addrs: + nb.patch("/ipam/ip-addresses/%d/" % _x["id"], {"status": DEPRECATED}) + for _x in dep_prefixes: + nb.patch("/ipam/prefixes/%d/" % _x["id"], {"status": DEPRECATED}) + + i.e. the tool now deprecates every ULA row BEFORE creating a single GUA record -- + precisely the forbidden ordering. Harness result: + + RESULT: PASS=14 FAIL=0 + ALL PASS + + The suite cannot see the defect it was written to prevent. + + FIX: test main() with a fake NB that RECORDS the call sequence, and assert that no + PATCH is issued before every POST has been issued and read back. Also assert the + failure branches: a POST that raises leaves zero PATCHes; a read-back that comes back + short leaves zero PATCHes. + + ORDERING IN THE SOURCE IS, HOWEVER, CORRECT AS WRITTEN (read, not executed): phase 1 + posts all creates; a RuntimeError returns 1 with "NOTHING WAS DEPRECATED"; phase 2 + re-reads /ipam/ip-addresses/ and returns 1 on any missing create, again before any + patch; phase 3 patches; phase 4 re-reads and verifies status. I found no path in the + source where a ULA row is deprecated while its GUA counterpart does not exist, EXCEPT + the vacuous case described in DEF-6 below. This is REASONED from source plus the + MEASURED dry run; the --commit path was not executed, per the review constraint. + + +DEF-5 [MEDIUM, MEASURED] T13, the case that names itself PROOF-OF-TEETH, is an assertion + on ABSENCE and therefore passes vacuously on ANY error in its own snippet. + + T13 is `if grep -q "MUTANT_FIRST=" ; then bad ; else ok ; fi`. Any output that + lacks that string satisfies it -- including a traceback. + + MUTATION M0, MEASURED. The tool file was replaced (in the COPY) with one line of + non-Python. Result: + + FAIL T1 .. FAIL T12 (all report "Traceback (most recent call last):") + PASS T13 PROOF-OF-TEETH: a wrong /48 changes the target, so T2 is a real assertion + PASS T14 tool contains NO delete path + RESULT: PASS=2 FAIL=12 + + A tool that does not even parse passes the teeth test. Standing rule, script-authoring + reference: "prove each new assertion can FAIL *and* prove it RAN." + + FIX: make T13 assert POSITIVELY -- require a MUTANT_FIRST line to be present AND to + differ from the correct target (two greps, not one negation). + + +DEF-6 [MEDIUM, MEASURED offline; NOT live today; reachable AFTER a first --commit run] + A pre-existing but DEPRECATED GUA record is accepted as ALREADY, and its ULA + counterpart is then deprecated too -- leaving the VIP with no usable apex row. + + plan() builds `existing` as a set of bare IPs (line 113) and the phase-2 read-back + likewise compares bare IPs only (lines 205-210). Neither consults status. + + MEASURED offline, pristine tool, full fixture plus one deprecated GUA record: + + D3 GUA counterpart exists but is DEPRECATED + -> CREATE=25 DEPA=26 DEPP=9 ALREADY=1 + ::50 in creates? False ULA ::50 in dep_addrs? True + + After commit BOTH the ULA and the GUA record for that VIP read deprecated. + + Not live now (there are zero deprecated addresses in the apex). It becomes reachable + the moment step 6 has run once and anyone flips a GUA row to deprecated -- i.e. it is + an IDEMPOTENCE hazard on re-run, not purely theoretical. It is also the only route I + found by which a ULA row ends up deprecated without a usable GUA counterpart, so it + bears directly on the brief's item 1. + + FIX: treat a non-reserved/non-active existing GUA record as a REFUSE, or PATCH it back + to VIP_STATUS as part of the create phase, and include status in the read-back + predicate. + + +DEF-7 [MEDIUM, MEASURED] T14's delete-path assertion covers ONE file, not the surface + the tool actually has. + + Item 6 of the brief, answered directly: AS WRITTEN TODAY there is NO delete path. + Verified by reading both files -- the only HTTP methods reachable are GET (get_all), + POST (post) and PATCH (NBW.patch). C.NB._req takes an arbitrary `method` string but no + caller passes DELETE. That is a clean finding. + + The ASSERTION that keeps it that way, however, greps "$TOOL" only. + + MUTATION M2, MEASURED. A delete() method was added to the COPY of the IMPORTED module + d139-gua-carve.py (whose NB class step 6 subclasses), leaving the step-6 file untouched: + + def delete(self, path): + return self._req("DELETE", path) + + RESULT: PASS=14 FAIL=0 + + T14 stays green while the class the tool inherits from has grown a delete path. The + ruling "Deprecate both, delete nothing" is repo-wide; the assertion is file-wide. + + FIX: grep both netbox/d139-step6-vip-rehome.py and netbox/d139-gua-carve.py, or assert + on the method set reachable from NBW. + + +DEF-8 [LOW, MEASURED offline; NOT live] An address inside a ULA /60 parent but outside + its /64 is silently dropped while the /60 is deprecated anyway. + + plan() skips any prefix with prefixlen != 64 (line 117-118) -- correct, and necessary: + MUTATION M6 (removing that guard) makes CREATE jump 26 -> 52 and T1/T6/T7 all fire. + THE GUARD IS DOING MORE THAN PREVENTING A DOUBLE-COUNT, and this is worth stating + because it changes how much weight it carries: fd50:840e:74e2:221::/64 (metal-internal) + is CONTAINED IN fd50:840e:74e2:220::/60 (role metal-admin). With the guard removed, the + /60 matches all 26 addresses and maps the 13 metal-INTERNAL VIPs onto + 2602:f3e2:f02:20::/64, the metal-ADMIN GUA plane -- 26 + 13 + 13 = 52, exactly the + measured figure. So that one `continue` is the only thing standing between the tool and + a silent CROSS-PLANE MIS-MAP. It must never be relaxed. + + But dep_prefixes deprecates ALL in-scope ULA prefixes including the /60s. MEASURED, + pristine tool, fixture plus one address at fd50:840e:74e2:225::7 (inside :220::/60, + outside :220::/64): + + CREATE=26 DEPA=26 DEPP=9 + stray created? False stray deprecated? False + BUT its /60 IS deprecated: True + + NOT LIVE: all 52 ULA addresses in the apex sit inside a /64. Latent only. + +DEF-9 [LOW, MEASURED offline; NOT live] Duplicate prefix ROWS for the same CIDR produce + duplicate CREATE payloads. + + NetBox permits two prefix rows with the same CIDR. plan() iterates rows, not CIDRs, and + the `already` check is against the pre-read `existing` set, never against creates + itself. MEASURED, pristine tool, fixture plus a second row for :220::/64: + + CREATE=39 DEPA=39 DEPP=10 + duplicate CREATE payloads: 13 (e.g. 2602:f3e2:f02:20::50/64) + + NOT LIVE: zero duplicate CIDRs measured apex-wide. Theoretical. + +DEF-10 [LOW, REASONED] ip_network.subnet_of() is true for a network against ITSELF, so if + fd50:840e:74e2::/48 ever exists as a prefix ROW scoped to a DC it would be deprecated + by this tool -- the org aggregate d139-gua-carve.py:48 calls "still a valid org + aggregate". MEASURED: no such row exists today (the 18 in-/48 rows are all /60 or /64). + Latent only; stated because the /48 is explicitly still-valid per the carve tool. + +DEF-11 [COSMETIC, MEASURED] tests/.../run-tests.sh defines run_case() at line 23-25 and + never calls it. Dead code in a harness whose job is to be trusted. + + +------------------------------------------------------------------------------- +ASSERTIONS THAT SURVIVED MUTATION +------------------------------------------------------------------------------- + +Mutation battery, each applied to a COPY, harness re-run, restored between runs: + + M0 tool replaced with unparseable text ..... T13, T14 PASS (see DEF-5) + M1b deprecate loops moved before creates .... ALL 14 PASS (see DEF-4) + M2 delete() added to imported carve module.. ALL 14 PASS (see DEF-7) + M3 VIP_STATUS reserved -> active ........... T4 fires + M4 ALREADY dedup removed ................... T6 fires + M5 ambiguity REFUSE -> pick first hextet ... T10 fires + M6 /60-parent skip removed ................. T1, T6, T7 fire + M7 dep_prefixes forced empty ............... T1, T7, T12 fire + M8 already-deprecated filter removed ....... T7 fires + + CONCLUSION: T1, T4, T6, T7, T10, T12 have real teeth against plan(). T2/T3 are proven + by T13's construction (M6/M7 also move them indirectly). T13 and T14 are the two that + survive everything (DEF-5, DEF-7), and NO assertion at all covers main() (DEF-4). + + CASE COUNT: claimed T1-T14; measured PASS=14 FAIL=0; count and labels agree. The + suite's cases all RAN. (Standing rule "assert the case count moved" -- satisfied.) + + Note on coupling: T1-T5 all grep one shared $out from a single python run. A crash in + that run turns five cases red together, which is correct but means the five are not + five independent signals. + + +------------------------------------------------------------------------------- +WHAT I COULD NOT CHECK +------------------------------------------------------------------------------- + + 1. THE --commit WRITE PATH WAS NEVER EXECUTED (review constraint). Phases 1-4 are + REASONED from source only. No POST, PATCH or read-back behaviour is measured. In + particular I did not observe how the apex responds to a duplicate ip-address POST, + nor whether enforce_global_unique is set on this NetBox. + 2. MAAS. The D-139 execution list step 6 reads "retire the ULA rows IN THE APEX AND IN + MAAS". This tool is apex-only and its docstring does not claim otherwise. I did not + check whether the MAAS half is covered by another tool or is still owed. FLAGGED as + an open scope question for the operator, not as a defect of this tool. + 3. Whether --dc vr1-dc1 is intended to be run in this window at all. DEF-1 is stated as a + missing guard regardless of intent; if dc1 is out of scope the fix may simply be to + restrict the flag. + 4. C.NB._req with method="DELETE" was never exercised; I verified by reading that no + caller passes it, not by running it. + 5. Real-world contents of ~/vr1-office1-creds/vr1-netbox-sandbox.env -- sourced only, + never read or printed, per the secrets rule. + 6. Concurrency. If anything else writes the apex between the tool's initial read and its + phase-3 patch, plan() is stale. Not evaluated. + 7. The 2602:f3e2:f02:11:: provider-public VIP records' provenance -- I confirmed they + EXIST and match the overlay, but did not trace which tool created them. + + +------------------------------------------------------------------------------- +REPO INTEGRITY STATEMENT +------------------------------------------------------------------------------- + + No repo file was edited by this review. All mutation experiments were performed on + copies under /home/jessea123/.claude/jobs/015c103e/tmp/mut/ , and the pre-mutation + copies were diffed against the repo originals at the end of the run: + + IDENTICAL: netbox/d139-step6-vip-rehome.py + IDENTICAL: netbox/d139-gua-carve.py + IDENTICAL: tests/d139-step6-vip-rehome/run-tests.sh + + The harness was re-run against the real repo files afterwards: PASS=14 FAIL=0. + bash scripts/repo-lint.sh -> 0 fail, 1 warn (the pre-existing L1 legacy-ASCII warn on + docs/design-decisions.md), 657 files scanned. + + NOTE, stated because it would otherwise look like my doing: `git status --short` + showed ONLY the two untracked targets at the start of this review, and later showed + docs/CURRENT-STATE.md, docs/design-decisions.md and tests/HARNESS-MANIFEST as modified. + I inspected those diffs: they are the status=active -> status=reserved correction and + the HARNESS-MANIFEST entry for d139-step6-vip-rehome -- concurrent edits from the + parent session, not from this review. No git add / commit / push was run. + + No cloud mutation of any kind was performed. All apex access was GET. + + +------------------------------------------------------------------------------- +SUMMARY OF REQUIRED FIXES BEFORE --commit +------------------------------------------------------------------------------- + + MUST DEF-1 precondition: every target GUA /64 must exist as a prefix row for --dc + MUST DEF-2 restore the SANDBOX_HOSTS apex-identity guard (reuse C.SANDBOX_HOSTS) + MUST DEF-3 assert the expected 26 / 26 / 9 floor; partial must not exit 0 + MUST DEF-4 harness must cover main()'s ordering and its two failure branches + SHOULD DEF-5 make T13 assert positively so it cannot pass on a traceback + SHOULD DEF-6 include status in the ALREADY / read-back predicate + SHOULD DEF-7 extend T14's grep to the imported module + MAY DEF-8..DEF-11 latent / cosmetic; record rather than fix if time is short + + The mapping logic, the never-delete posture, the status choice, the verbatim + descriptions and the source-level phase ordering are all correct and were verified. + This is a good tool missing its preconditions and missing the test for its own + headline property. diff --git a/docs/changelog-20260802-queued-items.md b/docs/changelog-20260802-queued-items.md index 52db97b..4a83c4e 100644 --- a/docs/changelog-20260802-queued-items.md +++ b/docs/changelog-20260802-queued-items.md @@ -458,3 +458,82 @@ - Changed: `docs/audit/mirror-500-timeout-rootcause-20260802.txt` (new capture). `scripts/dc-mirror.sh` STILL untouched. - **Revert:** n/a -- read-only investigation. + +## Item 10 -- D-139 step 6 tool BUILT, adversarially reviewed, FOUR defects fixed + +`netbox/d139-step6-vip-rehome.py` + `tests/d139-step6-vip-rehome/run-tests.sh` (20 cases). +Implements the two 2026-08-02 rulings: "Full step 6 first, then deploy" and "Deprecate +both, delete nothing". CREATE 26 GUA VIP addresses -> read-back verify -> deprecate 26 ULA +addresses + 9 ULA prefixes. **No delete path anywhere**, asserted against the artifact. + +**Dry run (live apex): `CREATE 26 | ALREADY 0 | DEPRECATE-ADDR 26 | DEPRECATE-PFX 9`.** +**The 26 CREATE targets diff EXACTLY against the 26 GUA VIP legs in +`overlays/vr1-dc0-vips.yaml`** -- every address written to the apex is one the deploy +configures, and vice versa. Nothing has been written; `--commit` has not been run. + +**AN ADVERSARIAL REVIEW RETURNED "FIX FIRST" AND IT WAS RIGHT ON ALL FOUR COUNTS.** +Capture: `docs/audit/d139-step6-tool-review-20260802.txt`. The mapping logic was correct; +what was missing was PRECONDITIONS and COVERAGE. + +- **DEF-1 (CRITICAL) -- `--dc vr1-dc1` would ORPHAN-CREATE.** `gua_net_for_role()` + computed targets arithmetically and never asked the apex whether they EXIST. MEASURED: + dc1's GUA carve is incomplete -- four rows under `2602:f3e2:f03::/48`, all + provider-public, no `:20::/64`, no `:21::/64` -- so dc1 planned 26 creates into + non-existent prefixes then deprecated dc1's only authoritative rows, at rc=0 with no + warning. **dc0 hid it because all sixteen of its targets happen to exist.** Reachable + via the other valid value of a required flag. FIXED: the target must exist in the apex + or the tool REFUSES. Verified live -- dc1 now refuses, dc0 unchanged at 26/26/9. +- **DEF-2 (CRITICAL) -- the apex-IDENTITY guard was gone.** `d139-gua-carve.py:159-163` + refuses any host that is not the working apex (DOCFIX-195), but that guard lives in its + `main()`, and importing a module never runs its `main()`. **Subclassing `C.NB` inherited + the TRANSPORT and left the SAFETY POSTURE behind** -- pointing this at + `netbox.baldurkeep.com` (the v1 reference) would have connected fine and written to the + wrong NetBox. FIXED: identity checked before any network call. +- **DEF-3 (HIGH) -- silent under-count.** Removing one ULA `/64` row yielded + `CREATE=13 / DEPA=13 / DEPP=8` at exit 0 -- 13 live VIPs neither created nor deprecated, + reported as success. Same class as DEFECT 2 of the 2026-08-01 CORRECTION NOTE. FIXED: + any ULA address claimed by no prefix row REFUSES. **My first fix was itself wrong and + running it caught that** -- it scanned the whole retired `/48` and flagged dc1's 26 VIPs + while planning dc0. The retired `/48` is SHARED (dc0 `:22x`, dc1 `:32x`), so the check + now excludes addresses another DC's row claims, and deliberately does NOT count a `/60` + parent as coverage -- the reviewer's scenario was exactly a missing `/64` whose `/60` + still existed. +- **DEF-4 (HIGH) -- `main()` had ZERO coverage.** The reviewer hoisted the deprecate loops + above the create phase and the suite reported ALL PASS. The CREATE-before-DEPRECATE + ordering both rulings call load-bearing was untested. FIXED: T16-T18 drive `main()` + through a fake client that RECORDS CALL ORDER. **Proven by re-running the reviewer's + exact mutation on a copy: T18 goes RED.** + +**TWO OF MY ASSERTIONS COULD NOT FAIL, and the review killed both.** **T13** asserted the +ABSENCE of a string, so a traceback satisfied it -- it passed against a tool file that did +not parse. Now requires a positive, well-formed, DIFFERENT target, and **T15** separately +asserts the tool parses, closing the escape hatch. **T14** grepped ONE file, so adding a +delete to the IMPORTED `d139-gua-carve.py` left it green; it now covers both. + +**Harness 14 -> 20 cases, count delta checked** (the rule landed in item 3 today, applied +to my own delivery). Gauntlet manifest recorded deliberately, 97 -> 98. + +**Corrected in the ruling record (GA-R1 C2):** the amendment said the GUA records would be +`status=active`. MEASURED: the live ULA VIP records are `reserved`, and +`dc-plane-apex-import.py:186,200` creates addresses `reserved`. Both +`docs/design-decisions.md` and `docs/CURRENT-STATE.md` corrected. + +**Also corrected -- my own overclaim:** the docstring said "every action is reversible by +flipping a status back". True of the 26+9 DEPRECATIONS; NOT true of the 26 CREATES, which +would need a delete this repo does not have. Reversal is not wanted (the creates are the +records the deploy matches) but the claim was wrong and is now stated precisely. + +**OPEN SCOPE QUESTION, MEASURED, NOT A TOOL DEFECT -- THE MAAS HALF OF STEP 6.** D-139's +list says retire the ULA rows "in the apex **and in MAAS**". This tool is apex-only, so +step 6 is NOT complete when it finishes. MEASURED on the dc0 region: MAAS holds BOTH +families -- five ULA `/64`s (`:220`, `:221`, `:230`, `:240`, `:250`) beside six GUA. Four +are EMPTY; **`fd50:840e:74e2:220::/64` (metal-admin) still holds 2 allocated entries**, so +this cannot be a blind cleanup. MAAS has no `deprecated` status for a subnet -- the choice +is delete or leave -- which makes it a separate operator decision, not an extension of the +"deprecate" ruling. + +- Changed: `netbox/d139-step6-vip-rehome.py` (new), `tests/d139-step6-vip-rehome/run-tests.sh` + (new, 20 cases), `tests/HARNESS-MANIFEST` (+1, recorded deliberately), + `docs/audit/d139-step6-tool-review-20260802.txt` (new), plus the two doc corrections. +- **Revert:** delete the tool, its harness dir and the manifest line; re-run + `bash scripts/run-tests-all.sh --record-manifest`. Nothing was written to the apex. diff --git a/docs/design-decisions.md b/docs/design-decisions.md index d74b145..5f4b8e0 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -7444,8 +7444,12 @@ **CONSEQUENCE, and it materially shrinks the risk the ordering ruling flagged.** Step 6 is: 1. CREATE the 26 GUA VIP ip-addresses (`2602:f3e2:f02:20::50-::62` metal-admin, - `2602:f3e2:f02:21::50-::62` metal-internal), `status=active`, descriptions mirroring the - ULA originals. + `2602:f3e2:f02:21::50-::62` metal-internal), **`status=reserved`**, descriptions mirroring + the ULA originals. **CORRECTED 2026-08-02 BY MEASUREMENT (GA-R1 C2): this line first read + `status=active`, which was an INFERENCE.** The live ULA VIP records carry + `status="reserved"`, and `netbox/dc-plane-apex-import.py:186,200` also creates addresses + `reserved`. The GUA records therefore mirror the originals rather than introducing a + status the repo does not use for addresses. 2. Set `status=deprecated` on the 26 ULA VIP ip-addresses. 3. Set `status=deprecated` on the 9 ULA prefix rows. diff --git a/netbox/d139-step6-vip-rehome.py b/netbox/d139-step6-vip-rehome.py new file mode 100644 index 0000000..be7b4cd --- /dev/null +++ b/netbox/d139-step6-vip-rehome.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +D-139 execution STEP 6, one DC: re-home the v6 VIP ip-addresses from the retiring ULA +/64s onto their GUA counterparts, then DEPRECATE the ULA addresses and the ULA prefixes. + + . ~/vr1-office1-creds/vr1-netbox-sandbox.env # the WORKING VR1 apex (DOCFIX-195) + python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0 # DRY RUN (the default) + python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0 --commit # writes, then reads back + +TWO OPERATOR RULINGS, both 2026-08-02 (GA-R5), quoted in docs/design-decisions.md: + ordering -- "Full step 6 first, then deploy" + semantics -- "Deprecate both, delete nothing" + +NOTHING IS EVER DELETED. "Retire" == status=deprecated. This preserves +d139-gua-carve.py's never-delete posture repo-wide -- that refusal is why the step-1 push +was safe to run. + +REVERSIBILITY, STATED PRECISELY because an earlier draft of this docstring overclaimed it: +the 26+9 DEPRECATIONS are reversible by flipping a status back. The 26 CREATES are NOT -- +undoing them would need a delete, and this repo has no delete path by design. The creates +are additive and are exactly the records the deploy configures, so reversal is not wanted; +but "every action is reversible" was wrong and is corrected here. + +ORDER IS LOAD-BEARING AND IS NOT AN IMPLEMENTATION DETAIL: CREATE every GUA record, +VERIFY by read-back, and only THEN deprecate. Reversed, there is an interval in which the +apex marks a live VIP's only record unusable -- the same orphaning hazard DEFECT 3 of the +2026-08-01 CORRECTION NOTE identified for prefixes. + +WHY IT DERIVES THE ULA->GUA MAP FROM THE ROLE SLUG rather than from hextet arithmetic: +the ULA and GUA hextets differ per DC (dc0's metal-admin is fd50:...:220 -> 2602:f3e2:f02:20) +and a transposition would be silent. Role slug is the stable key both sides share, and it +comes from d139-gua-carve.py's own CARVE table, which is IMPORTED rather than retyped. +If a role maps to more than one GUA /64 (provider-public owns :10 and :11) the tool +REFUSES rather than picking -- an ambiguous mapping is an unrecognised state. + +MEASURED, not assumed (2026-08-02, live apex): the existing ULA VIP ip-addresses carry +status="reserved" -- NOT "active" -- and role/dns_name/tenant/vrf/tags/custom_fields are all +empty. dc-plane-apex-import.py:186,200 also creates addresses as "reserved". So the GUA +records are created RESERVED, mirroring the originals, and only address + status + +description carry meaning. + +Exit: 0 ok | 1 write/read-back error | 2 REFUSE (could not evaluate). +""" +import argparse +import importlib.util +import ipaddress +import os +import sys +import urllib.parse + +_HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _load_carve(): + """Import d139-gua-carve.py by path -- its hyphenated name is not importable normally. + Reusing its NB client and CARVE table is deliberate: a second hand-copied table is + exactly the transposition this repo keeps finding.""" + p = os.path.join(_HERE, "d139-gua-carve.py") + spec = importlib.util.spec_from_file_location("d139_gua_carve", p) + if spec is None or spec.loader is None: + print("REFUSE: cannot load %s" % p, file=sys.stderr) + sys.exit(2) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +C = _load_carve() +die = C.die + +# The status the GUA VIP records are CREATED with. MEASURED from the ULA originals +# (2026-08-02): they are "reserved", not "active". See module docstring. +VIP_STATUS = "reserved" +DEPRECATED = "deprecated" + + +class NBW(C.NB): + """The carve tool's client plus PATCH -- it never needed one, having no update path.""" + + def patch(self, path, body): + return self._req("PATCH", path, body) + + +def gua_net_for_role(dc, role, live_gua=None): + """The GUA /64 carrying `role` for this DC, from the imported CARVE table. + + REFUSES on an ambiguous role rather than choosing, and -- when `live_gua` is + supplied -- REFUSES unless the computed /64 ACTUALLY EXISTS in the apex. + + DEF-1 (adversarial review 2026-08-02, CRITICAL): computing the target + arithmetically and never asking the apex whether it exists is a silent + orphan-create. MEASURED: vr1-dc1's GUA carve is INCOMPLETE -- only four rows exist + under 2602:f3e2:f03::/48, all provider-public, with no :20::/64 and no :21::/64 -- + so `--dc vr1-dc1` planned 26 creates into prefixes that DO NOT EXIST and then + deprecated dc1's only authoritative rows, at rc=0 with no warning. dc0 hid the bug + because all sixteen of its targets happen to exist. The imported carve tool checks + its own preconditions ("refusing to half-carve a DC"); this one checked none. + """ + base48 = ipaddress.ip_network(C.DC_GUA[dc]) + hextets = [nn for nn, r, _lbl, _parent in C.CARVE if r == role] + if not hextets: + die("no D-139 GUA plane carries apex role %r -- cannot re-home its addresses" % role) + if len(hextets) > 1: + die("apex role %r maps to %d GUA /64s (%s) -- ambiguous, refusing to choose" + % (role, len(hextets), ", ".join("%#x" % h for h in hextets))) + net = C.sub_at(base48, hextets[0], 64) + if live_gua is not None and str(net) not in live_gua: + die("target GUA prefix %s (role %r) DOES NOT EXIST in the apex for %s. Step 1/2 " + "have not carved this DC, so creating addresses inside it would orphan them. " + "Run netbox/d139-gua-carve.py --dc %s --commit first." % (net, role, dc, dc)) + return net + + +def plan(dc, prefixes, addrs): + """Returns (creates, dep_addrs, dep_prefixes, already). + + creates -- GUA ip-address payloads to POST + dep_addrs -- ULA ip-address rows to mark deprecated + dep_prefixes -- ULA prefix rows to mark deprecated + already -- GUA addresses that already exist (idempotent re-run) + """ + ula48 = ipaddress.ip_network(C.RETIRED_ULA_48) + gua48 = ipaddress.ip_network(C.DC_GUA[dc]) + + # DEF-1: the set of GUA prefixes that ACTUALLY EXIST for this DC. gua_net_for_role() + # refuses against this, so a target is never computed into thin air. + live_gua = {str(ipaddress.ip_network(p["prefix"])) for p in prefixes + if C.scope_slug(p) == dc} + + # The ULA prefixes in scope: this DC's, inside the retired /48. Same selection the + # carve tool's RETIRE-REPORT makes, so the two cannot disagree about scope. + ula_pfx = [] + for p in prefixes: + if C.scope_slug(p) != dc: + continue + n = ipaddress.ip_network(p["prefix"]) + if n.version == 6 and n.subnet_of(ula48): + ula_pfx.append((n, p)) + if not ula_pfx: + die("no ULA prefixes in scope for %s -- nothing to do, and that is not a state this " + "tool should report as success (step 1/2 may target a different apex)" % dc) + + existing = {str(ipaddress.ip_interface(a["address"]).ip) for a in addrs} + + creates, dep_addrs, already = [], [], [] + for n, p in ula_pfx: + if n.prefixlen != 64: + continue # /60 parents hold no addresses; deprecated below + role = (p.get("role") or {}).get("slug") + if not role: + die("ULA prefix %s has no apex role -- cannot map it to a GUA plane" % n) + gua = gua_net_for_role(dc, role, live_gua) + for a in addrs: + ai = ipaddress.ip_interface(a["address"]) + if ai.version != 6 or ai.ip not in n: + continue + offset = int(ai.ip) - int(n.network_address) + tgt = ipaddress.ip_address(int(gua.network_address) + offset) + dep_addrs.append(a) + if str(tgt) in existing: + already.append((str(tgt), a.get("description", ""))) + else: + creates.append({ + "address": "%s/%d" % (tgt, gua.prefixlen), + "status": VIP_STATUS, + # Description kept VERBATIM: it names the service and the plane, both + # still true on GUA, and the 2026-08-01 conformance audit identified + # these objects BY description. Inventing new text would break that. + "description": a.get("description", ""), + }) + + # DEF-3 (adversarial review 2026-08-02, HIGH): SILENT UNDER-COUNT. Deleting or + # re-scoping ONE ULA /64 prefix row yielded CREATE=13 / DEPA=13 / DEPP=8 at exit 0 -- + # thirteen live VIPs neither created nor deprecated, reported as success. Same class + # as DEFECT 2 of the 2026-08-01 CORRECTION NOTE (a carve that does less and exits + # clean). The prefix ROW is not the authority on what exists; the ADDRESSES are. + # So: every v6 address of this DC inside the retired /48 must be covered by an + # in-scope /64. Any that is not is an unrecognised state, and unrecognised REFUSES + # rather than being quietly skipped. + # Addresses belonging to ANOTHER DC are legitimately out of scope -- the retired /48 + # is shared (dc0 uses :22x, dc1 uses :32x). Scoping this check to the whole /48 was a + # FALSE POSITIVE caught by running it: it flagged dc1's 26 VIPs while planning dc0. + # So an address is accounted for if EITHER this DC processed it, OR some other DC's + # ULA prefix row covers it. What remains -- inside the retired /48, in none of this + # DC's processed /64s, and claimed by no other DC -- is the real under-count signal. + # Note a /60 PARENT does not count as coverage: the reviewer's scenario was exactly a + # missing /64 row whose /60 parent still existed, and treating the parent as coverage + # would re-open the hole this guard closes. + other_dc_ula = [] + for p in prefixes: + if C.scope_slug(p) == dc: + continue + try: + n = ipaddress.ip_network(p["prefix"]) + except ValueError: + continue + if n.version == 6 and n.subnet_of(ula48): + other_dc_ula.append(n) + + covered = {id(a) for a in dep_addrs} + orphans = [] + for a in addrs: + try: + ai = ipaddress.ip_interface(a["address"]) + except ValueError: + continue + if ai.version != 6 or ai.ip not in ula48: + continue + if id(a) in covered: + continue + if any(ai.ip in n for n in other_dc_ula): + continue # another DC's row owns it -- not our business + orphans.append(a) + if orphans: + die("%d ULA ip-address(es) inside %s are claimed by NO prefix row -- not by an " + "in-scope /64 for %s, and not by any other DC. Their /64 row is missing, " + "mis-scoped or roleless, and processing the rest would silently do less than " + "step 6 requires. First: %s" + % (len(orphans), ula48, + dc, ", ".join(a["address"] for a in orphans[:4]))) + + dep_prefixes = [p for _n, p in ula_pfx + if (p.get("status") or {}).get("value") != DEPRECATED] + dep_addrs = [a for a in dep_addrs + if (a.get("status") or {}).get("value") != DEPRECATED] + return creates, dep_addrs, dep_prefixes, already + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--dc", required=True, choices=sorted(C.DC_GUA)) + ap.add_argument("--commit", action="store_true", + help="apply; without it this is a DRY RUN and writes nothing") + a = ap.parse_args() + + url, tok = os.environ.get("NETBOX_URL"), os.environ.get("NETBOX_TOKEN") + if not url or not tok: + die("set NETBOX_URL and NETBOX_TOKEN from the env (never argv -- it would land in " + "shell history): . ~/vr1-office1-creds/vr1-netbox-sandbox.env") + + # DEF-2 (adversarial review 2026-08-02, CRITICAL): the apex-IDENTITY guard. It exists + # in d139-gua-carve.py's main() (:159-163), and importing that module runs its + # constants and classes but NEVER its main() -- so subclassing C.NB inherited the + # TRANSPORT and left the SAFETY POSTURE behind. Identity is checked BEFORE any network + # call, because reachability is not identity: pointing this at the v1 reference would + # connect fine and write to the wrong NetBox. + host = urllib.parse.urlsplit(url).hostname or "" + if host not in C.SANDBOX_HOSTS: + die("'%s' is not the VR1 working apex. office1-netbox (10.10.1.10) takes ALL VR1 " + "reads and writes; netbox.baldurkeep.com is the v1 REFERENCE and stays " + "untouched (DOCFIX-195)." % host) + + nb = NBW(url, tok) + try: + prefixes = nb.get_all("/ipam/prefixes/") + addrs = nb.get_all("/ipam/ip-addresses/") + except RuntimeError as e: + die("apex unreachable or rejecting: %s" % e) + + creates, dep_addrs, dep_prefixes, already = plan(a.dc, prefixes, addrs) + + print("D-139 STEP 6 -- %s -- apex %s" % (a.dc, url)) + print(" CREATE %d | ALREADY %d | DEPRECATE-ADDR %d | DEPRECATE-PFX %d" + % (len(creates), len(already), len(dep_addrs), len(dep_prefixes))) + + print("\n CREATE (GUA, status=%s):" % VIP_STATUS) + for c in creates: + print(" %-34s %s" % (c["address"], c["description"])) + if already: + print("\n ALREADY PRESENT (idempotent -- left untouched):") + for addr, desc in already: + print(" %-34s %s" % (addr, desc)) + print("\n DEPRECATE ip-addresses (ULA -- NOT deleted):") + for x in dep_addrs: + print(" %-34s %s" % (x["address"], x.get("description", ""))) + print("\n DEPRECATE prefixes (ULA -- NOT deleted):") + for x in dep_prefixes: + print(" %-34s role=%s" % (x["prefix"], (x.get("role") or {}).get("slug"))) + + if not a.commit: + print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.") + return 0 + + # ---- PHASE 1: CREATE. Nothing is deprecated until every create is read back. ---- + try: + for c in creates: + nb.post("/ipam/ip-addresses/", c) + except RuntimeError as e: + print("FAIL: create: %s" % e, file=sys.stderr) + print("NOTHING WAS DEPRECATED -- the ULA records are untouched and still " + "authoritative. Re-run; creates are idempotent.", file=sys.stderr) + return 1 + + # ---- PHASE 2: VERIFY BY READ-BACK, against the artifact and not the response. ---- + try: + back = {str(ipaddress.ip_interface(x["address"]).ip) + for x in nb.get_all("/ipam/ip-addresses/")} + except RuntimeError as e: + die("read-back failed: %s" % e) + missing = [c["address"] for c in creates + if str(ipaddress.ip_interface(c["address"]).ip) not in back] + if missing: + print("FAIL: %d GUA address(es) not present on read-back: %s" + % (len(missing), ", ".join(missing)), file=sys.stderr) + print("NOTHING WAS DEPRECATED -- the ULA records remain authoritative.", + file=sys.stderr) + return 1 + print("\n read-back OK: %d GUA VIP address(es) present" % len(creates)) + + # ---- PHASE 3: DEPRECATE. Only now, and never a delete. ---- + try: + for x in dep_addrs: + nb.patch("/ipam/ip-addresses/%d/" % x["id"], {"status": DEPRECATED}) + for x in dep_prefixes: + nb.patch("/ipam/prefixes/%d/" % x["id"], {"status": DEPRECATED}) + except RuntimeError as e: + print("FAIL: deprecate: %s" % e, file=sys.stderr) + print("The GUA records ARE created and verified; some ULA rows may still read " + "active. Re-run -- deprecation is idempotent.", file=sys.stderr) + return 1 + + # ---- PHASE 4: verify the deprecation landed, again on the artifact. ---- + try: + pfx2 = {p["id"]: p for p in nb.get_all("/ipam/prefixes/")} + adr2 = {x["id"]: x for x in nb.get_all("/ipam/ip-addresses/")} + except RuntimeError as e: + die("post-deprecate read-back failed: %s" % e) + bad = [x["address"] for x in dep_addrs + if (adr2.get(x["id"], {}).get("status") or {}).get("value") != DEPRECATED] + bad += [p["prefix"] for p in dep_prefixes + if (pfx2.get(p["id"], {}).get("status") or {}).get("value") != DEPRECATED] + if bad: + print("FAIL: %d row(s) did not take status=deprecated: %s" + % (len(bad), ", ".join(bad)), file=sys.stderr) + return 1 + + print(" read-back OK: %d address(es) + %d prefix(es) now deprecated" + % (len(dep_addrs), len(dep_prefixes))) + print("\nSTEP 6 APPLIED. Nothing was deleted; every change is reversible by " + "flipping a status back.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index bdb15e2..0559953 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -14,6 +14,7 @@ d120-compose-bands d124-transit-seed d139-gua-carve +d139-step6-vip-rehome dc-cache-proxy dc-dc-ceph-disk-budget dc-dc-dr-drill diff --git a/tests/d139-step6-vip-rehome/run-tests.sh b/tests/d139-step6-vip-rehome/run-tests.sh new file mode 100644 index 0000000..0408bb0 --- /dev/null +++ b/tests/d139-step6-vip-rehome/run-tests.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +# tests/d139-step6-vip-rehome/run-tests.sh +# +# Offline harness for netbox/d139-step6-vip-rehome.py (D-139 execution step 6). +# Exercises plan() against fixtures shaped like the live NetBox API. No network. +# +# The tool's write path is CREATE -> verify -> DEPRECATE and never deletes; plan() is +# what decides all three sets, so it is where the risk lives. +# +# PROOF-OF-TEETH is a first-class case here (T13): this repo has twice shipped +# assertions that could not fail. Each REFUSE case is also proven to be reached for the +# stated reason, not incidentally. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TOOL="$REPO/netbox/d139-step6-vip-rehome.py" +PASS=0; FAIL=0 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } + +echo "== d139-step6-vip-rehome: plan() against API-shaped fixtures ==" +[ -r "$TOOL" ] || { echo " FAIL tool missing at $TOOL"; echo "RESULT: PASS=0 FAIL=1"; exit 1; } + +run_case() { # run_case