Newer
Older
openstack-caracal-ipv4 / fix-bundle-metal-vips.py
#!/usr/bin/env python3
"""
BUNDLEFIX-006 (D-020): append the metal HA VIP to each clustered API charm's `vip` option.

For every line of the form `vip: 10.12.4.<N>` where N is in the reserved provider API-VIP
range (224..254), rewrite it to `vip: "10.12.4.<N> 10.12.8.<N>"` so the charm advertises a
provider VIP (public endpoint) AND a metal VIP (internal/admin endpoints). This is the
spaces-native dual-VIP fix validated live on placement: internal/admin bindings = metal, so
resolve_address matches the metal VIP; public binding = provider, matches the provider VIP.
No binding/anchor change and no os-*-network needed.

Safety properties (same pattern as the prior fix scripts):
  - pure line edit; never round-trips YAML, so anchors/aliases/comments are preserved
  - STRICT match: only single `10.12.4.<224-254>` values are rewritten; anything else (already
    dual, out of range, unexpected format) is left untouched -> fail-safe, never mangles
  - idempotent: lines already carrying a `10.12.4.x 10.12.8.x` pair are skipped
  - timestamped .bak, unified diff to stdout, and a best-effort yaml.safe_load semantic check
    (skipped where PyYAML is absent, e.g. the Windows workstation; the jumphost re-verifies)
"""
import sys
import re
import datetime
import shutil
import difflib

PROVIDER_NET = "10.12.4."
METAL_NET = "10.12.8."
VIP_LO, VIP_HI = 224, 254  # reserved API-VIP range (same last-octet on both nets)

VIP_LINE = re.compile(r'^(?P<indent>\s*)vip:\s*(?P<q>["\']?)(?P<val>[^"\'\n]*)(?P=q)\s*$')
SINGLE = re.compile(r'^10\.12\.4\.(\d+)$')
DOUBLE = re.compile(r'^10\.12\.4\.(\d+)\s+10\.12\.8\.(\d+)$')


def main():
    if len(sys.argv) != 2:
        print("usage: fix-bundle-metal-vips.py <bundle.yaml>")
        return 2
    path = sys.argv[1]
    try:
        with open(path) as f:
            original = f.read()
    except OSError as e:
        print(f"[ABORT] cannot read {path}: {e}")
        return 3

    lines = original.split("\n")
    changed = 0
    skipped_already = 0
    untouched_unexpected = []

    out = []
    for l in lines:
        m = VIP_LINE.match(l)
        if m:
            val = m.group("val").strip()
            if DOUBLE.match(val):
                skipped_already += 1
                out.append(l)
                continue
            sm = SINGLE.match(val)
            if sm:
                octet = int(sm.group(1))
                if VIP_LO <= octet <= VIP_HI:
                    out.append(f'{m.group("indent")}vip: "{PROVIDER_NET}{octet} {METAL_NET}{octet}"')
                    changed += 1
                    continue
            # vip line, but not a single in-range provider VIP -> leave alone, but note it
            untouched_unexpected.append(val)
        out.append(l)

    if untouched_unexpected:
        print(f"[NOTE] {len(untouched_unexpected)} vip line(s) left untouched (unexpected value/range): "
              f"{untouched_unexpected}")

    if changed == 0:
        if skipped_already:
            print(f"[OK/IDEMPOTENT] {skipped_already} vip line(s) already carry a metal VIP; no change.")
            return 0
        print("[ABORT] found no `vip: 10.12.4.224-254` lines to update.")
        return 4

    new = "\n".join(out)
    if original.endswith("\n") and not new.endswith("\n"):
        new += "\n"

    print("=== unified diff ===")
    sys.stdout.writelines(difflib.unified_diff(
        original.splitlines(keepends=True),
        new.splitlines(keepends=True),
        fromfile=f"{path} (orig)", tofile=f"{path} (new)"))

    try:
        import yaml
        d = yaml.safe_load(new)
        apps = d.get("applications", {}) or {}
        dual = sorted(
            a for a, c in apps.items()
            if isinstance(c, dict) and isinstance(c.get("options"), dict)
            and isinstance(c["options"].get("vip"), str)
            and len(c["options"]["vip"].split()) == 2
        )
        print(f"\n[VERIFY] yaml parses OK; {len(dual)} charm(s) now have a 2-address vip:")
        for a in dual:
            print(f"           {a}: {apps[a]['options']['vip']}")
    except ImportError:
        print("\n[VERIFY] PyYAML not present (Windows workstation) - semantic check skipped; "
              "jumphost will re-verify after pull.")
    except Exception as e:
        print(f"\n[ABORT] yaml verify failed, not writing: {e}")
        return 5

    ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
    bak = f"{path}.bak-{ts}"
    shutil.copy2(path, bak)
    with open(path, "w") as f:
        f.write(new)
    print(f"\n[WROTE] {path}  (backup: {bak})")
    print(f"[SUMMARY] updated {changed} vip line(s); {skipped_already} already dual; "
          f"{len(untouched_unexpected)} untouched.")
    return 0


if __name__ == "__main__":
    sys.exit(main())