diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cc98278..e6c5ae9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1413,6 +1413,56 @@ guarded tools expose a deliberate `--yes-write-upstream` override; and `~/vr1-office1-creds/vr1-netbox.env` points at the v1 reference while `vr1-netbox-sandbox.env` points at the LIVE apex, i.e. the filenames are inverted). +- **RENDER-PIPELINE STEP 4 -- THE RENDERER SHIPPED 2026-07-29, and it REPRODUCES a reviewed + artifact BYTE-FOR-BYTE.** Operator direction: "You can order the steps in whatever order you + want. Deploy agents to help if needed." `scripts/render-dc-overlays.py` (harness + `tests/render-dc-overlays` **17/17**; gauntlet **ALL GREEN (85) on vcloud**, was 84; manifest + re-recorded deliberately after the drift gate caught the addition). Changelog + `docs/changelog-20260728-vip-arity-gate.md`. + **THE ORDER WAS INVERTED BY MEASUREMENT, and the reason is the durable part.** The plan was + ruling-3's VIP extraction first, then the renderer. Three read-only agents measured two facts + that reversed it: the byte-for-byte reproduction WINDOW IS STILL OPEN (the live dc1 overlay is + still identical to the frozen fixture, `3f93ecb3...`) and closes on its own the moment the + R2/R11 reconciliation lands; and the extraction's blast radius is FAR larger than ruling 3's + "2 commits" -- it breaks a DEPLOY GATE (`runbooks/phase-01-bundle-deploy.md:170-176`, where + `juju deploy` runs only on `11/11/0`), the Octavia SAN derivation (`:371`), preflight in two + places, and 8 of 30 harness cases. So the extraction is better done BY the renderer, making + dc0's overlay a validated output rather than a hand edit. + **TWO STAGES, split where forward item F2 FROZE the interface:** `derive` (apex + `lib-net.sh` + -> a per-DC VALUES file) and `render` (VALUES -> overlay TEXT), the latter PURE -- no apex, no + network, no clock -- so it is offline-testable and a future CI job re-runs only `derive`. It is + a TEXT emitter, not `yaml.dump`: a round-tripper destroys the comment header, normalises the + quoted `vip` scalar and imposes its own key order, all three load-bearing. Two things are INPUT + rather than invented -- the 12-line comment header (lifted VERBATIM via `--from-overlay`, so a + reproduction run is honest about which bytes the tool generates) and the ASCENDING-OCTET + emission order (sorting by name reproduces nothing). The ruled app->octet map is READ with + `ast` out of `netbox/dc-plane-apex-import.py`, not restated as a second drifting copy. + **PROVEN ABLE TO FAIL, not merely observed passing:** output hashes to `3f93ecb3...`, identical + to both the committed overlay (1634 bytes) and the frozen fixture -- and three seeded faults + all fail the compare (wrong octet, dropped header, dc0 prefixes against the dc1 artifact). + `tests/render-baseline` gains **T6**, the reproduction case its README invited (ADDED; T1-T5 + deliberately NOT repointed at live files, and T6 compares against the FIXTURE because live is + supposed to diverge). T6 was itself proven able to fail by making the renderer sort + alphabetically. render-baseline **10/10** (was 9). + **THE 2026-07-28 ARITY WORK PAID FOR ITSELF WITHIN THE HOUR.** The renderer's FIRST dual-family + output was malformed: the values file stores each v6 `/64` base with trailing colons stripped, + and the emitter joined with a SINGLE colon, yielding `2602:f3e2:f03:11:50` -- not a valid + address, and it still looks like one. `provider-bundle-check` rejected all 13 applications with + `bad vip ip`. Fixed and locked by harness cases T13/T13b. **This is the acceptance criterion the + arity fix existed to provide, working on its first real use.** + **END-TO-END CHAIN NOW DEMONSTRATED: apex -> values -> renderer -> overlay -> gate.** The + dual-family dc1 render with R11's ruled-but-unbuilt apps included produces **13 clustered VIPs, + all dual-family**, and `provider-bundle-check --dc vr1-dc1` reports PASS including the new + hacluster invariant. **NOTHING COMMITTED WAS REGENERATED BY IT YET** -- ruling-3's extraction + is the next step and is where the blast radius above must be repaired. + **LOGGED NOT ACTIONED (agent findings):** F3's "MACs sourced from `lib-hosts.sh`" is FALSIFIED + (lib-hosts carries only BOOT MACs; `ovn-chassis` needs the provider NIC, which lives in + `opentofu/vr1-dcN-substrate/main.tf` `macs[1]` -- and exists for BOTH DCs, narrowing this + document's "dc0's cannot be derived" to "not from a pattern"); `dc-plane-ipam.sh:120` lacks + `scope.slug` in its matcher (latent -- it defaults to a repo dump); `pre-flight-checks.sh` + CHECK 1 is triple-only AND dc0-only and cannot evaluate dc1 at all; `render-baseline` T3 + compares counts not sets; and the **B1/B5 comment tokens are defined in `bundle.yaml`'s own + header**, so migrating the inline VIP comments out would strand them. - **THREE PLATFORM BEHAVIOURS GRADUATED to `references/platform-traps.md`** at session close, having been recorded only in this status document (which is consolidated over time, so a durable trap does not belong here alone): MAAS auto-reserves `::1`-`::ffff:ffff` on EVERY diff --git a/docs/changelog-20260728-vip-arity-gate.md b/docs/changelog-20260728-vip-arity-gate.md index e95026e..f225fd0 100644 --- a/docs/changelog-20260728-vip-arity-gate.md +++ b/docs/changelog-20260728-vip-arity-gate.md @@ -164,3 +164,133 @@ 5. **The NetBox `?site=` API filter silently does not filter.** Measured this session: `/api/ipam/ip-addresses/?site=vr1-dc0` and `?site=vr1-dc1` each returned the full 160. A per-site apex count taken that way would be wrong and look right. + +--- + +# 2026-07-29 -- render-pipeline STEP 4: the renderer + +Same branch, continued. Operator direction: **"You can order the steps in whatever +order you want. Deploy agents to help if needed."** + +## 4. Ordering decision, and why it INVERTED after measurement + +I first proposed doing ruling-3's VIP extraction before the renderer, then reversed +it once three read-only agents reported. Recording the reversal rather than the +conclusion, because the reason is the reusable part: + +- The byte-for-byte reproduction WINDOW IS STILL OPEN and closes on its own. + `overlays/vr1-dc1-vips.yaml` is still byte-identical to + `tests/render-baseline/fixtures/vr1-dc1-vips.v4-11app.yaml` (`3f93ecb3...`) + -- verified this session. The reconciliation (11->13 apps, dual-family) destroys it. +- The extraction's blast radius is FAR larger than ruling 3's "2 commits". Measured: + it breaks a DEPLOY GATE (`runbooks/phase-01-bundle-deploy.md:170-176`, where + `juju deploy` runs only on `11/11/0`), the Octavia SAN derivation (`:371`), + preflight in two places, and 8 of 30 harness cases. +- So the extraction is better done BY the renderer, once the renderer is proven -- + which makes dc0's overlay a validated output rather than a hand edit. + +## 5. `scripts/render-dc-overlays.py` -- two stages, split where F2 froze the interface + +**What.** `derive` (apex + `lib-net.sh` -> a per-DC VALUES file) and `render` +(VALUES -> overlay TEXT). Plus `check`, which renders and byte-compares. + +**Why split.** Forward item F2 froze the interface: *"the renderer is a +deterministic command-line tool whose inputs are the per-DC values file and whose +outputs are committed, reviewable artifacts. Nothing about automating it should +require changing the renderer."* `render` is therefore PURE -- no apex, no network, +no clock -- so it is offline-testable and a future CI job only re-runs `derive`. + +**Why a text emitter, not `yaml.dump`.** The target is byte-compared. A round-tripper +destroys the comment header, normalises `vip: "a b c"` to a plain scalar, and imposes +its own key order -- all three are load-bearing. PyYAML reads the values file and +never writes an overlay. + +**Two things that are INPUT, not output, and would otherwise be invented:** +- The 12-line comment header cites one-off measurements and a design-doc fork; none + of it is computable from the apex. `derive --from-overlay` lifts it VERBATIM from + the artifact being reproduced, so a reproduction run is honest about which bytes + the tool actually generates. +- Emission order is ASCENDING OCTET, not alphabetical (keystone .50 before barbican + .51). Sorting by name reproduces nothing. + +**The ruled app->octet map is READ, not restated** -- parsed with `ast` out of +`netbox/dc-plane-apex-import.py`'s `APP_OCTET`, the tool that wrote those values into +the apex. A second hand-maintained copy is a second thing to drift. + +**Revert.** Delete `scripts/render-dc-overlays.py`, `tests/render-dc-overlays/`, +`render/values/`, and revert the `render-baseline` T6 hunk + the manifest line. +Nothing else consumes any of it; no committed artifact was regenerated by it yet. + +## 6. RESULT: byte-for-byte reproduction achieved, and PROVEN ABLE TO FAIL + +`render` output hashes to +`3f93ecb3205cea8b303f5b2cbbbde343a2762156575277ca84684369da8bf896` -- identical to +both the committed overlay (1634 bytes) and the frozen fixture. + +A byte-compare only ever observed PASSING is as untrustworthy as a gate only ever +observed failing, so three faults were seeded and all three fail it: a wrong octet, a +dropped header, and dc0 prefixes against the dc1 artifact. `render-baseline`'s new T6 +was separately proven by making the renderer sort alphabetically -- hash changed, +case went red. + +## 7. THE ARITY WORK PAID FOR ITSELF WITHIN THE HOUR -- a real renderer bug, caught by the gate + +The renderer's FIRST dual-family output was malformed. The values file stores each +v6 `/64` base with trailing colons stripped (`2602:f3e2:f03:11`), and the emitter +joined with a SINGLE colon -- yielding `2602:f3e2:f03:11:50`. That is not a valid +address, and it still looks like one. + +`provider-bundle-check.py` -- fixed yesterday for exactly this class -- rejected all +13 applications with `bad vip ip`. Fixed to `::%d` and locked by harness cases +T13/T13b, which assert both that the correct form is present AND that no +single-colon leg is emitted. **This is the acceptance criterion the whole arity fix +existed to provide, working on its first real use.** + +## 8. End-to-end chain now demonstrated + +apex -> values -> renderer -> overlay -> gate. The dual-family dc1 render with R11's +ruled-but-unbuilt apps included produces **13 clustered VIPs, all dual-family**, and +`provider-bundle-check --dc vr1-dc1` reports **PASS** including the new hacluster +invariant (12 principals, all carrying a VIP). + +**Evidence.** New harness `tests/render-dc-overlays` **17/17**; `render-baseline` +**10/10** (was 9, T6 added -- ADDED per its README, existing assertions NOT repointed). +Gauntlet **ALL GREEN (85 harnesses) on vcloud** (was 84); `tests/HARNESS-MANIFEST` +re-recorded deliberately after the drift gate caught the addition -- which is the gate +working. repo-lint 0 fail / 608 files scanned. + +--- + +## Findings from the three read-only agents -- LOGGED, NOT ACTIONED (hard rule 1) + +6. **F3's MAC sourcing claim is FALSIFIED.** `docs/dc-dc-deployment-workflow.md:361` + says `ovn-chassis` MACs "stay sourced from `scripts/lib-hosts.sh`". Measured: + lib-hosts carries only `HOST_BOOT_MAC` (the PXE NIC, `...:NN:01`); `ovn-chassis` + needs the provider-public NIC (`...:NN:02`), which is **not in lib-hosts at all**. + The real source is `opentofu/vr1-dcN-substrate/main.tf`, `macs[1]` of each compute + node -- and it exists for BOTH DCs. This also narrows CURRENT-STATE's claim that + dc0's MACs "cannot be derived": not from a PATTERN, but they are pinned literals + in tofu and are perfectly sourceable. F3's conclusion (MACs are renderer output, + not apex input) still stands; only its sourcing sentence is wrong. +7. **`scripts/dc-plane-ipam.sh:120` has a latent scope-matcher bug.** It matches + `scope.name` or `scope_site` but NOT `scope.slug`, so it would match ZERO prefixes + against a live-shaped record. Latent only because it defaults to a repo dump. The + other two tools use the three-way matcher; this one is the odd one out. +8. **`pre-flight-checks.sh` CHECK 1 is triple-only AND dc0-only.** It hardcodes + `if(n!=3)` (`:86`), reads `bundle.yaml` as RAW TEXT (never a merged input), and + never calls `lib_net_select_dc` -- and `lib-net.sh` deliberately UNSETS + `VIP_PREFIX_*` for dc1, so CHECK 1 cannot evaluate dc1 at all. The renderer's + dual-family output will pass `provider-bundle-check` and FAIL preflight P4. +9. **CURRENT-STATE is stale on dc1's FIP pool** -- an early paragraph still says it + "needs a ruling"; a later entry records it RULED and applied, and `lib-net.sh` + carries the values. Same in-document supersession class this doc keeps finding. +10. **`render-baseline` T3 compares COUNTS, not SETS.** A deleted pinned fixture plus + an added unpinned one keeps the count equal; a `#` comment line in `SHA256SUMS` + inflates the pin count and masks one unpinned file; and `-maxdepth 1` cannot see + a subdirectory. It catches the fault it was seeded against; a set-difference + would close all three. +11. **The B1/B5 comment tokens are defined in `bundle.yaml`'s own header** (`:11-13`, + `:19-24`). Ten of the eleven inline VIP comments are the bare token `# B1`. + Migrating them into an overlay STRANDS them from their definitions -- + `appendix-A:652-661` records the repo's rule for exactly this identifier class. + This must be handled when the extraction lands, not discovered then. diff --git a/render/values/vr1-dc1-vips.yaml b/render/values/vr1-dc1-vips.yaml new file mode 100644 index 0000000..f3234d6 --- /dev/null +++ b/render/values/vr1-dc1-vips.yaml @@ -0,0 +1,59 @@ +dc: vr1-dc1 +output: overlays/vr1-dc1-vips.yaml +family: v4 +legs: +- provider-public +- metal-admin +- metal-internal +prefixes_v4: + provider-public: 10.12.64 + metal-admin: 10.12.68 + metal-internal: 10.12.72 +apps: +- name: keystone + octet: 50 +- name: barbican + octet: 51 +- name: cinder + octet: 52 +- name: glance + octet: 53 +- name: magnum + octet: 54 +- name: neutron-api + octet: 55 +- name: nova-cloud-controller + octet: 56 +- name: octavia + octet: 57 +- name: openstack-dashboard + octet: 58 +- name: placement + octet: 59 +- name: ceph-radosgw + octet: 60 +header: '# overlays/vr1-dc1-vips.yaml + + # Per-DC VIP overlay for vr1-dc1 (the runbook''s "DC2"). D-135 amendment / + + # phase-4 Step 4: bundle.yaml hardcodes the API VIPs in vr1-dc0''s bands, so + + # deploying vr1-dc1 re-homes them into ITS bands. Base bundle stays the vr1-dc0 + + # source of truth; this overlay is applied only for the dc1 deploy: + + # juju deploy ./bundle.yaml --overlay ./overlays/vr1-dc1-vips.yaml + + # + + # Mapping (MEASURED from scripts/lib-net.sh lib_net_select_dc vr1-dc1, PLANE_NAME): + + # provider-public 10.12.4 -> 10.12.64 metal-admin 10.12.8 -> 10.12.68 + + # metal-internal 10.12.12 -> 10.12.72 (host octets .50-.60 UNCHANGED) + + # The three VIPs per service are public / admin / internal API endpoints + + # (D-101 inherits the family layout; only the network prefix moves per DC). + + ' diff --git a/scripts/render-dc-overlays.py b/scripts/render-dc-overlays.py new file mode 100755 index 0000000..c3a1be5 --- /dev/null +++ b/scripts/render-dc-overlays.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +render-dc-overlays.py -- deterministic per-DC deploy-overlay renderer (D-136 option (D)). + +D-136 was ADOPTED at option (D) 2026-07-27: the NetBox apex is the source, the +renderer emits per-DC deploy artifacts, and this deployment is NOT gated on it. + +TWO STAGES, deliberately split -- this is the interface forward item F2 FROZE +("the renderer is a deterministic command-line tool whose inputs are the per-DC +values file and whose outputs are committed, reviewable artifacts. Nothing about +automating it should require changing the renderer."): + + derive : apex + lib-net.sh -> a per-DC VALUES file (needs the repo; reads + the apex record and shells out to lib-net; this is the half a CI job + would re-run) + render : VALUES file -> overlay TEXT on stdout (pure, offline, + deterministic -- no apex, no lib-net, no network, no clock) + +WHY A TEXT EMITTER AND NOT yaml.dump: the target artifacts are byte-compared +against frozen fixtures (tests/render-baseline/). A YAML round-tripper destroys +the comment header, normalises `vip: "a b c"` to a plain scalar, and imposes its +own key order -- all three are load-bearing bytes. PyYAML is used for READING the +values file, never for writing an overlay. + +KEY ORDER IS NOT ALPHABETICAL. Applications are emitted in ascending VIP octet +(keystone .50 ... ceph-radosgw .60), which is the order the reviewed artifacts +already use. Sorting by name reproduces nothing. + +THE COMMENT HEADER IS INPUT, NOT OUTPUT. It cites one-off measurements and a +design-doc fork; none of it is computable from the apex. `derive --from-overlay` +lifts it verbatim from the artifact being reproduced, so a reproduction run is +honest about which bytes the tool actually generates. + +Exit: 0 ok | 1 mismatch/refused | 2 could-not-evaluate. ASCII + LF. +""" +import sys, os, json, glob, argparse, subprocess, ipaddress + +try: + import yaml +except ImportError: + sys.stderr.write("ERROR: PyYAML not installed (pip install pyyaml --break-system-packages)\n") + sys.exit(2) + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APEX_ENV, APEX_GLOB = "APEX_RECORD", "netbox/draft/vr1-office1-current-*.json" + +# The ruled app -> VIP octet map. Single source is netbox/dc-plane-apex-import.py:49-56 +# (the tool that wrote these into the apex); duplicated here would be a second +# hand-maintained copy, so it is READ from that file rather than restated. +OCTET_SRC = os.path.join(REPO, "netbox", "dc-plane-apex-import.py") + +# The three VIP legs, in the column order every reviewed artifact already uses. +LEGS = ("provider-public", "metal-admin", "metal-internal") + + +def die(msg, rc=2): + sys.stderr.write("ERROR: %s\n" % msg) + sys.exit(rc) + + +def app_octets(): + """The ruled app->octet list, read from dc-plane-apex-import.py's APP_OCTET. + + Read rather than restated: a second copy is a second thing to drift. Parsed + with ast so importing that module (which reaches for env credentials at + import time is NOT the case, but which does carry a write path) is avoided. + """ + import ast + try: + tree = ast.parse(open(OCTET_SRC, encoding="utf-8").read()) + except Exception as e: + die("cannot read the ruled octet map from %s: %s" % (OCTET_SRC, e)) + for node in tree.body: + if isinstance(node, ast.Assign) and any( + getattr(t, "id", "") == "APP_OCTET" for t in node.targets): + return [(k.value, v.value) for k, v in + ((e.elts[0], e.elts[1]) for e in node.value.elts)] + die("APP_OCTET not found in %s" % OCTET_SRC) + + +def lib_net_planes(dc): + """Plane name -> CIDR from lib-net.sh, the site-keyed v4 authority. + + Same shell-out as netbox/dc-plane-apex-import.py:96-105. lib_net_select_dc + refuses a second selection in one shell, so this runs a fresh bash per DC. + """ + sh = ('source "%s/scripts/lib-net.sh" >/dev/null 2>&1 || exit 3; ' + 'lib_net_select_dc "%s" >/dev/null 2>&1 || exit 3; ' + 'for c in "${PLANE_CIDRS[@]}"; do printf "%%s\\t%%s\\n" "${PLANE_NAME[$c]}" "$c"; done' + % (REPO, dc)) + p = subprocess.run(["bash", "-c", sh], capture_output=True, text=True) + if p.returncode != 0 or not p.stdout.strip(): + die("lib-net.sh did not yield planes for %s (rc=%d)" % (dc, p.returncode)) + return dict(l.split("\t") for l in p.stdout.strip().splitlines()) + + +def apex_v6(dc): + """(role, kind) -> v6 /64 for `dc`, from the apex record. + + Scope matching is the THREE-WAY form (scope.slug / scope.name / scope_site). + The live API returns scope.name as a DISPLAY name with scope.slug as the site + key, while the repo dumps flatten to scope_site -- a tool written against one + shape matches ZERO objects on the other, which has already happened once here. + """ + rec = os.environ.get(APEX_ENV) + if not rec: + cand = sorted(glob.glob(os.path.join(REPO, APEX_GLOB))) + rec = cand[-1] if cand else None + if not rec or not os.path.isfile(rec): + die("no readable apex record (looked for %s; set %s)" % (APEX_GLOB, APEX_ENV)) + try: + doc = json.load(open(rec, encoding="utf-8")) + except Exception as e: + die("apex record %s unreadable: %s" % (rec, e)) + out = {} + for p in (doc.get("ipam/prefixes") or []): + pre = str(p.get("prefix", "")) + if ":" not in pre or not pre.endswith("/64"): + continue + sc = p.get("scope") or {} + if (sc.get("slug") or sc.get("name") or p.get("scope_site") or "") != dc: + continue + role = p.get("role") or "" + role = role.get("slug") if isinstance(role, dict) else role + out[(role, "vip" if "VIP" in str(p.get("description") or "") else "plane")] = pre + if not out: + die("apex record %s carries no /64 prefixes scoped to %s" % (rec, dc)) + return out, rec + + +def do_derive(a): + planes = lib_net_planes(a.dc) + missing = [l for l in LEGS if l not in planes] + if missing: + die("%s is missing plane(s) %s in lib-net -- refusing to guess a VIP leg" + % (a.dc, ", ".join(missing))) + v4 = {} + for leg in LEGS: + net = ipaddress.ip_network(planes[leg]) + v4[leg] = str(net.network_address).rsplit(".", 1)[0] # e.g. 10.12.64 + + vals = {"dc": a.dc, "output": a.output or "overlays/%s-vips.yaml" % a.dc, + "family": "v4", "legs": list(LEGS), "prefixes_v4": v4} + + if a.dual_family: + v6map, rec = apex_v6(a.dc) + pre6 = {} + for leg in LEGS: + key = ("provider-public", "vip") if leg == "provider-public" else (leg, "plane") + if key not in v6map: + die("no apex v6 prefix for %s in %s -- refusing to invent one" % (key, a.dc)) + pre6[leg] = str(ipaddress.ip_network(v6map[key]).network_address).rstrip(":") + vals["family"] = "dual" + vals["prefixes_v6"] = pre6 + vals["apex_record"] = os.path.relpath(rec, REPO) + + octets = app_octets() + if not a.include_unbuilt: + # vault .61 / designate .62 are RULED-BUT-NOT-BUILT (R11). They are in the + # apex by design -- reserving a planned address is what an IPAM apex is for + # -- but emitting them into a deploy artifact is a SEPARATE ruled step, so + # they are excluded unless asked for. Reproducing today's 11-app artifact + # depends on this default. + known = {"vault", "designate"} + octets = [(n, o) for n, o in octets if n not in known] + vals["apps"] = [{"name": n, "octet": o} for n, o in octets] + + if a.from_overlay: + hdr = [] + for line in open(a.from_overlay, encoding="utf-8").read().splitlines(): + if line.startswith("#"): + hdr.append(line) + else: + break + if not hdr: + die("%s carries no leading comment header to lift" % a.from_overlay) + vals["header"] = "\n".join(hdr) + "\n" + + txt = yaml.safe_dump(vals, sort_keys=False, default_flow_style=False, width=4096) + (open(a.out, "w", encoding="utf-8") if a.out else sys.stdout).write(txt) + return 0 + + +def render(vals): + """VALUES -> overlay text. Pure: no I/O, no clock, no network.""" + fam = vals.get("family", "v4") + legs, pre4 = vals["legs"], vals["prefixes_v4"] + pre6 = vals.get("prefixes_v6") or {} + out = [] + if vals.get("header"): + out.append(vals["header"] if vals["header"].endswith("\n") else vals["header"] + "\n") + out.append("applications:\n") + # ascending octet -- NOT alphabetical; this is the order the reviewed + # artifacts use and sorting by name reproduces nothing. + for app in sorted(vals["apps"], key=lambda x: int(x["octet"])): + o = int(app["octet"]) + addrs = ["%s.%d" % (pre4[l], o) for l in legs] + if fam == "dual": + # `::%d`, not `:%d`. The values file stores the /64 base with its + # trailing colons stripped (2602:f3e2:f03:11), so the separator has to + # put BOTH back -- a single colon yields 2602:f3e2:f03:11:50, which is + # a malformed address that still LOOKS like one. This was the + # renderer's first dual-family output and provider-bundle-check caught + # it; the octet mirror is textual, so the digits stay decimal. + addrs += ["%s::%d" % (pre6[l], o) for l in legs] + out.append(" %s:\n options:\n" % app["name"]) + if fam == "dual": + # prefer-ipv6 and the v6 legs must land together, or HAProxy binds + # :::port with no v6 VIP for pacemaker to manage. + out.append(" prefer-ipv6: true\n") + out.append(' vip: "%s"\n' % " ".join(addrs)) + return "".join(out) + + +def load_values(path): + try: + v = yaml.safe_load(open(path, encoding="utf-8")) + except Exception as e: + die("cannot parse values file %s: %s" % (path, e)) + for k in ("dc", "legs", "prefixes_v4", "apps"): + if k not in (v or {}): + die("values file %s lacks required key %r" % (path, k)) + if v.get("family") == "dual" and "prefixes_v6" not in v: + die("values file %s declares family=dual but carries no prefixes_v6" % path) + return v + + +def main(): + ap = argparse.ArgumentParser(description="Deterministic per-DC overlay renderer (D-136 (D))") + sub = ap.add_subparsers(dest="cmd", required=True) + + d = sub.add_parser("derive", help="apex + lib-net -> a per-DC values file") + d.add_argument("--dc", required=True, choices=["vr1-dc0", "vr1-dc1"]) + d.add_argument("--from-overlay", metavar="FILE", + help="lift the comment header verbatim from an existing overlay") + d.add_argument("--dual-family", action="store_true", + help="include the v6 legs (R2) -- needs the apex record") + d.add_argument("--include-unbuilt", action="store_true", + help="also emit vault .61 / designate .62 (R11, ruled but not built)") + d.add_argument("--output", help="the `output:` path recorded IN the values file") + d.add_argument("-o", "--out", help="write the values file here (default stdout)") + + r = sub.add_parser("render", help="values file -> overlay text on stdout") + r.add_argument("values") + r.add_argument("-o", "--out", help="write here instead of stdout") + + c = sub.add_parser("check", help="render and byte-compare against an existing artifact") + c.add_argument("values") + c.add_argument("--against", required=True, metavar="FILE") + + a = ap.parse_args() + if a.cmd == "derive": + return do_derive(a) + if a.cmd == "render": + txt = render(load_values(a.values)) + (open(a.out, "w", encoding="utf-8") if a.out else sys.stdout).write(txt) + return 0 + # check + got = render(load_values(a.values)) + try: + want = open(a.against, encoding="utf-8").read() + except Exception as e: + die("cannot read %s: %s" % (a.against, e)) + if got == want: + print(" [ok] byte-for-byte match: %s (%d bytes)" % (a.against, len(want))) + print("PASS: rendered output reproduces %s exactly" % a.against) + return 0 + import difflib + print(" [FAIL] rendered output DIFFERS from %s" % a.against) + gl, wl = got.splitlines(True), want.splitlines(True) + for line in list(difflib.unified_diff(wl, gl, "committed", "rendered", n=1))[:40]: + sys.stdout.write(" " + line if line.endswith("\n") else " " + line + "\n") + print("FAIL: %d rendered line(s) vs %d committed" % (len(gl), len(wl))) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index e5ccc62..ab59405 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -67,6 +67,7 @@ record-audit reenroll-hosts render-baseline +render-dc-overlays repo-lint roles-aggregates-import sandbox-fidelity-check diff --git a/tests/render-baseline/run-tests.sh b/tests/render-baseline/run-tests.sh index 6e49e0d..cf72e48 100755 --- a/tests/render-baseline/run-tests.sh +++ b/tests/render-baseline/run-tests.sh @@ -84,6 +84,34 @@ n_tab="$(grep -v '^#' "$FIX/bundle-vips.v4-11app.txt" | grep -Pc '\t' 2>/dev/null || echo 0)" t_eq "T5b every bundle fixture row is tab-separated" "11" "$n_tab" +# T6 -- THE REPRODUCTION CASE the renderer was frozen for (added 2026-07-29 when +# scripts/render-dc-overlays.py landed; README:71-72 says ADD this, and do +# NOT repoint T1-T5 at live files). +# +# It compares against the FIXTURE, deliberately NOT overlays/vr1-dc1-vips.yaml: +# the live overlay is about to be rewritten by the R2/R11 reconciliation and +# the fixture is not, so pointing this at live would turn the harness red for +# the very change it exists to support -- the tests/creds-matrix T24 trap this +# whole directory was built to avoid. +# +# SKIPS (does not fail) if the renderer is absent, so this file remains valid +# at any commit before the renderer landed. A skip is REPORTED, never silent. +REN="$(cd "$HERE/../.." && pwd)/scripts/render-dc-overlays.py" +if [ ! -f "$REN" ]; then + echo " SKIP T6 renderer not present at this commit ($REN)" +else + _t6="$(mktemp -d)" + if python3 "$REN" derive --dc vr1-dc1 --from-overlay "$FIX/vr1-dc1-vips.v4-11app.yaml" \ + --output overlays/vr1-dc1-vips.yaml -o "$_t6/v.yaml" >/dev/null 2>&1; then + got="$(python3 "$REN" render "$_t6/v.yaml" 2>/dev/null | sha256sum | cut -d' ' -f1)" + want="$(sha256sum "$FIX/vr1-dc1-vips.v4-11app.yaml" | cut -d' ' -f1)" + t_eq "T6 renderer reproduces the frozen fixture byte-for-byte" "$want" "$got" + else + bad "T6 renderer could not derive a values file for vr1-dc1" + fi + rm -rf "$_t6" +fi + echo echo "RESULT: PASS=$PASS FAIL=$FAIL" [ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/render-dc-overlays/run-tests.sh b/tests/render-dc-overlays/run-tests.sh new file mode 100755 index 0000000..84c6db4 --- /dev/null +++ b/tests/render-dc-overlays/run-tests.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# tests/render-dc-overlays/run-tests.sh +# +# Offline regression harness for scripts/render-dc-overlays.py (D-136 option (D)). +# No live infra, no network. Mutates NOTHING outside $TMP. +# +# THE CENTRAL ASSERTION is byte-for-byte reproduction of a REVIEWED artifact +# (overlays/vr1-dc1-vips.yaml). A renderer validated only against its own output +# is validated against nothing -- that is the objection D-136 raises against its +# own option (A). So the reproduction case is paired with THREE seeded faults +# proving the comparison can fail: a wrong octet, a dropped header, and wrong-DC +# prefixes. A byte-compare only ever observed passing is as untrustworthy as a +# gate only ever observed failing. +# +# Exit: 0 all pass | 1 any case failed. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +R="$REPO/scripts/render-dc-overlays.py" +CHK="$REPO/scripts/provider-bundle-check.py" +LIVE="$REPO/overlays/vr1-dc1-vips.yaml" +FIX="$REPO/tests/render-baseline/fixtures/vr1-dc1-vips.v4-11app.yaml" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +t_eq(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2' want '$3')"; fi; } + +echo "== render-dc-overlays: deterministic per-DC overlay renderer ==" + +# ---- derive ------------------------------------------------------------------ +V="$TMP/dc1.yaml" +if python3 "$R" derive --dc vr1-dc1 --from-overlay "$LIVE" \ + --output overlays/vr1-dc1-vips.yaml -o "$V" 2>"$TMP/e1"; then + ok "T1 derive vr1-dc1 from apex + lib-net succeeds" +else + bad "T1 derive failed: $(head -1 "$TMP/e1")" +fi + +n_apps="$(python3 -c "import yaml,sys; print(len(yaml.safe_load(open(sys.argv[1]))['apps']))" "$V" 2>/dev/null)" +t_eq "T2 derive defaults to the 11 BUILT apps (vault/designate excluded)" "$n_apps" "11" + +# ---- the central assertion --------------------------------------------------- +if python3 "$R" check "$V" --against "$LIVE" >/dev/null 2>&1; then + ok "T3 render reproduces overlays/vr1-dc1-vips.yaml BYTE-FOR-BYTE" +else + bad "T3 render does NOT reproduce the committed overlay" +fi + +# T4 the same bytes as the FROZEN fixture, which is the durable target: the live +# overlay is about to be rewritten by the R2/R11 reconciliation, the fixture is not. +got="$(python3 "$R" render "$V" 2>/dev/null | sha256sum | cut -d' ' -f1)" +want="$(sha256sum "$FIX" | cut -d' ' -f1)" +t_eq "T4 rendered output matches the frozen render-baseline fixture hash" "$got" "$want" + +# ---- proven able to FAIL (three seeded faults) -------------------------------- +sed 's/octet: 50/octet: 51/' "$V" > "$TMP/f1.yaml" +python3 "$R" check "$TMP/f1.yaml" --against "$LIVE" >/dev/null 2>&1 +t_eq "T5 a wrong octet FAILS the byte-compare" "$?" "1" + +python3 - "$V" "$TMP/f2.yaml" <<'PY' +import sys, yaml +v = yaml.safe_load(open(sys.argv[1])); v.pop("header", None) +yaml.safe_dump(v, open(sys.argv[2], "w"), sort_keys=False, width=4096) +PY +python3 "$R" check "$TMP/f2.yaml" --against "$LIVE" >/dev/null 2>&1 +t_eq "T6 a dropped comment header FAILS the byte-compare" "$?" "1" + +python3 "$R" derive --dc vr1-dc0 --from-overlay "$LIVE" -o "$TMP/f3.yaml" 2>/dev/null +python3 "$R" check "$TMP/f3.yaml" --against "$LIVE" >/dev/null 2>&1 +t_eq "T7 dc0 prefixes against the dc1 artifact FAIL (cross-DC)" "$?" "1" + +# ---- determinism + purity ---------------------------------------------------- +a="$(python3 "$R" render "$V" 2>/dev/null | sha256sum)" +b="$(python3 "$R" render "$V" 2>/dev/null | sha256sum)" +t_eq "T8 render is deterministic across runs" "$a" "$b" + +# T9 render is PURE -- no apex, no network. A v4 values file must render with the +# apex record deliberately unreachable, or the renderer is not offline-testable +# and forward item F2's frozen interface is violated. +out="$(APEX_RECORD=/nonexistent/apex.json python3 "$R" render "$V" 2>/dev/null | sha256sum | cut -d' ' -f1)" +t_eq "T9 render needs no apex (pure, offline)" "$out" "$want" + +# T10 ...but DERIVING dual-family does, and must REFUSE rather than invent one. +APEX_RECORD=/nonexistent/apex.json python3 "$R" derive --dc vr1-dc1 --dual-family \ + -o "$TMP/nope.yaml" >/dev/null 2>&1 +t_eq "T10 derive --dual-family REFUSES an unreadable apex (rc=2)" "$?" "2" + +# ---- ordering ---------------------------------------------------------------- +# T11 emission order is ASCENDING OCTET, not alphabetical. Sorting by name +# reproduces nothing: keystone(.50) must precede barbican(.51). +first="$(python3 "$R" render "$V" 2>/dev/null | grep -E '^ [a-z]' | head -1 | tr -d ' :')" +t_eq "T11 apps emit in ascending-octet order, not alphabetical" "$first" "keystone" + +# ---- dual-family (R2) -------------------------------------------------------- +DV="$TMP/dual.yaml" +python3 "$R" derive --dc vr1-dc1 --dual-family --include-unbuilt \ + --from-overlay "$LIVE" -o "$DV" >/dev/null 2>&1 +n2="$(python3 -c "import yaml,sys; print(len(yaml.safe_load(open(sys.argv[1]))['apps']))" "$DV" 2>/dev/null)" +t_eq "T12 --include-unbuilt adds R11's vault .61 + designate .62" "$n2" "13" + +# T13 the v6 separator regression. The values file stores the /64 base with its +# trailing colons stripped, so a single-colon join yields 2602:f3e2:f03:11:50 +# -- malformed, and it still LOOKS like an address. This was the renderer's +# first dual-family output; provider-bundle-check caught it. Locked here. +if python3 "$R" render "$DV" 2>/dev/null | grep -q '2602:f3e2:f03:11::50'; then + ok "T13 v6 legs use the '::' separator (the malformed-but-plausible regression)" +else + bad "T13 v6 leg separator wrong -- check for a single-colon join" +fi +if python3 "$R" render "$DV" 2>/dev/null | grep -qE '2602:f3e2:f03:11:[0-9]+ '; then + bad "T13b a single-colon v6 leg is present" +else + ok "T13b no single-colon v6 leg emitted" +fi + +# T14 the end-to-end acceptance criterion: the renderer's dual-family output is +# VALID DEPLOY INPUT per the gate. This is what the 2026-07-28 arity work +# exists to provide -- without it the dry-run below means nothing. +mkdir -p "$TMP/policies" +cp "$REPO/bundle.yaml" "$TMP/b.yaml" +cp "$REPO/policies/domain-manager-policy.yaml" "$REPO/policies/overrides.zip" "$TMP/policies/" +python3 "$R" render "$DV" -o "$TMP/vips.yaml" 2>/dev/null +if python3 "$CHK" "$TMP/b.yaml" --overlay "$TMP/vips.yaml" --dc vr1-dc1 >"$TMP/gate" 2>&1; then + ok "T14 rendered dual-family overlay PASSES provider-bundle-check" +else + bad "T14 rendered overlay rejected by the gate: $(grep -m1 FAIL "$TMP/gate")" +fi +if grep -q "13 clustered VIP(s).*13 dual-family" "$TMP/gate"; then + ok "T15 the gate sees 13 VIPs, all dual-family" +else + bad "T15 gate did not report 13 dual-family VIPs" +fi + +# ---- refusals ---------------------------------------------------------------- +printf 'dc: vr1-dc1\nlegs: [a]\n' > "$TMP/bad.yaml" +python3 "$R" render "$TMP/bad.yaml" >/dev/null 2>&1 +t_eq "T16 a values file missing required keys REFUSES (rc=2)" "$?" "2" + +echo +echo "RESULT: PASS=$PASS FAIL=$FAIL" +[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1