Newer
Older
openstack-caracal-dc-dc / scripts / render-dc-overlays.py
#!/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 VIP octet band (D-020 amendment / R11). Kept in step with
# provider-bundle-check.py OCTET_LO/HI and dc-plane-apex-import.py BAND_VIP.
OCTET_LO, OCTET_HI = 50, 99

# 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")

# Which charms DECLARE a `prefer-ipv6` option. Same read-don't-restate rule: the
# measured authority is PREFER_IPV6_CHARMS in provider-bundle-check.py (the gate), and
# it carries the measurement, the revisions and the re-measure warning.
#
# NOTE ON KEYING, stated because it is a real (small) gap: that tuple is keyed by CHARM
# name, and a values file only carries APPLICATION names. Measured 2026-07-31: for all
# thirteen VIP applications in bundle.yaml the two are IDENTICAL, so matching on the app
# name is correct today. It is NOT guaranteed in general -- an application may be named
# anything. The charm-keyed assertion lives in provider-bundle-check invariant 9a, which
# reads the merged bundle and therefore SEES the charm; so if the two ever diverge the
# gate fails rather than the artifact silently going wrong. The renderer generates; the
# gate decides.
PREFER6_SRC = os.path.join(REPO, "scripts", "provider-bundle-check.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):
            pairs = [(k.value, v.value) for k, v in
                     ((e.elts[0], e.elts[1]) for e in node.value.elts)]
            # REFUSE rather than emit. Measured (2026-07-29 chain audit): a duplicate app
            # name emitted TWO blocks for one app, `yaml.safe_load` silently kept the last,
            # and the gate passed -- one app's ruled octet vanished with nothing red. A
            # duplicate octet or an out-of-band one would be caught downstream by the gate,
            # but catching it HERE names the actual cause instead of a symptom.
            names = [n for n, _ in pairs]
            octs = [o for _, o in pairs]
            dupn = sorted({n for n in names if names.count(n) > 1})
            dupo = sorted({o for o in octs if octs.count(o) > 1})
            bad = sorted({o for o in octs if not (OCTET_LO <= int(o) <= OCTET_HI)})
            if dupn:
                die("APP_OCTET in %s has duplicate app name(s): %s" % (OCTET_SRC, ", ".join(dupn)))
            if dupo:
                die("APP_OCTET in %s has duplicate octet(s): %s" % (OCTET_SRC, ", ".join(map(str, dupo))))
            if bad:
                die("APP_OCTET in %s has octet(s) outside the ruled %d-%d band: %s"
                    % (OCTET_SRC, OCTET_LO, OCTET_HI, ", ".join(map(str, bad))))
            return pairs
    die("APP_OCTET not found in %s" % OCTET_SRC)


def prefer6_charms():
    """The charms that DECLARE prefer-ipv6, read from provider-bundle-check.py.

    Read rather than restated, for the same reason as APP_OCTET: a second copy is a
    second thing to drift, and this one is a PINNED MEASUREMENT that has to be redone
    when a channel moves.

    REFUSES rather than defaulting. An empty or missing tuple would silently render
    every app without the option -- which is a plausible-looking artifact and exactly
    the "could not look is not nothing there" failure this repo keeps hitting. An
    unreadable source is an error, not a licence to emit.
    """
    import ast
    try:
        tree = ast.parse(open(PREFER6_SRC, encoding="utf-8").read())
    except Exception as e:
        die("cannot read PREFER_IPV6_CHARMS from %s: %s" % (PREFER6_SRC, e))
    for node in tree.body:
        if isinstance(node, ast.Assign) and any(
                getattr(t, "id", "") == "PREFER_IPV6_CHARMS" for t in node.targets):
            try:
                names = [e.value for e in node.value.elts]
            except Exception as e:
                die("PREFER_IPV6_CHARMS in %s is not a flat tuple of strings: %s"
                    % (PREFER6_SRC, e))
            if not names or not all(isinstance(x, str) and x for x in names):
                die("PREFER_IPV6_CHARMS in %s is empty or holds a non-string entry"
                    % PREFER6_SRC)
            return frozenset(names)
    die("PREFER_IPV6_CHARMS not found in %s" % PREFER6_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
        key = (role, "vip" if "VIP" in str(p.get("description") or "") else "plane")
        # REFUSE on a duplicate key. Measured (2026-07-29 chain audit): this loop was
        # last-writer-wins, so adding ONE spare prefix to the apex -- routine IPAM work --
        # silently re-homed every dc0 VIP into a different /64 and the whole chain went
        # GREEN, because the gate only checks overlay-vs-apex AGREEMENT and both read the
        # same wrong value. Which prefix won depended on JSON array order, which is not a
        # contract.
        if key in out and out[key] != pre:
            die("apex record %s has TWO %s/%s /64s for %s (%s and %s) -- refusing to pick "
                "one by array order" % (rec, key[0], key[1], dc, out[key], pre))
        out[key] = 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,
            # Provenance, recorded on BOTH families. The v4 prefixes come from
            # lib-net.sh and NEVER from the apex, so a values file that carried
            # only `apex_record` would misattribute half its own inputs.
            "source_v4": "scripts/lib-net.sh lib_net_select_dc %s" % a.dc}

    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, prefer6=None):
    """VALUES -> overlay text. Pure: no I/O, no clock, no network.

    `prefer6` is the set of charms that DECLARE a prefer-ipv6 option. It is a
    PARAMETER rather than a lookup so this function keeps its purity property -- the
    reason the artifacts are byte-reproducible. Callers resolve it once with
    prefer6_charms(); the default is an explicit REFUSAL, never an empty set, because
    an empty set renders a plausible-looking artifact with the option nowhere.
    """
    if prefer6 is None:
        die("render() called without the prefer-ipv6 charm set -- resolve it with "
            "prefer6_charms() and pass it in; defaulting to empty would silently emit "
            "an artifact with the option nowhere")
    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" and app["name"] in prefer6:
            # RULED 2026-07-31 (D-101 RULING NOTE, GA-R5: "Yes -- keep the v6 legs,
            # remove only the option"). This used to be emitted for EVERY dual-family
            # app, on the belief that prefer-ipv6 is what makes HAProxy bind :::port.
            # MEASURED FALSE: that bind is gated on `ipv6_enabled = not
            # is_ipv6_disabled()`, a read of the kernel disable_ipv6 sysctl, and
            # pacemaker picks IPv6addr by address-family detection. Six of the thirteen
            # VIP charms do not declare the option at all, and juju rejects the WHOLE
            # bundle on an unknown option -- which is how bundle deploy attempt 1 died
            # on 2026-07-31. The v6 legs stay; only the option is withheld.
            # Capture: docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt
            out.append("      prefer-ipv6: true\n")
        # An optional trailing comment, carried so the ruling-3 extraction out of
        # bundle.yaml is LOSSLESS -- ten of those eleven comments are the bare
        # token `# B1`, whose definition stays in bundle.yaml's header block, and
        # three carry real operational content. An app with no comment emits none,
        # so the dc1 artifact (which has none) still reproduces byte-for-byte.
        cmt = (app.get("comment") or "").rstrip()
        out.append('      vip: "%s"%s\n' % (" ".join(addrs), (" " + cmt) if cmt else ""))
    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), prefer6_charms())
        (open(a.out, "w", encoding="utf-8") if a.out else sys.stdout).write(txt)
        return 0
    # check
    got = render(load_values(a.values), prefer6_charms())
    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())