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 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,
            # 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):
    """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")
        # 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))
        (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())