#!/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())