#!/usr/bin/env python3
"""
fix-bundle-v1.py - Option-A bundle fix for the Caracal v1 deploy.
SUPERSEDES fix-api-bindings.py (which did only edits 1-2). Run this against the
*original* bundle.yaml (i.e. after `git restore bundle.yaml` if the earlier
script was already applied).
All edits are text/line based - YAML is never round-tripped, so anchors,
aliases, comments and formatting are preserved. Verification uses safe_load
(read only). Aborts WITHOUT writing if the bundle is not in the expected
pre-fix shape.
Edits:
1. Shrink &api-bindings to the two keys that carry meaning:
"": metal
public: provider
(drops admin/internal/shared-db/amqp/certificates/cluster/ha - all 'metal',
i.e. the "" default - which were causing 'unknown endpoint' deploy errors
on keystone / ceph-radosgw / openstack-dashboard.)
2. vault bindings: *api-bindings -> *internal-bindings (vault is internal-only).
3. Remove vault's options: block (vip + os-public-hostname) - vault is a
single unit on the testcloud (3 at Roosevelt); a provider VIP is both
unreachable from metal-bound vault and pointless at one unit.
4. Comment out the vault-hacluster subordinate.
5. Comment out the [vault:ha, vault-hacluster:ha] relation.
Net effect on counts: VIPs 11->10, apps 51->50, relations 98->97.
Vault HA is restored at Roosevelt where it is genuinely 3-unit with a real
(metal) VIP from NetBox.
Usage: python3 fix-bundle-v1.py [path-to-bundle.yaml] (default ./bundle.yaml)
"""
import sys, os, re, shutil, difflib, datetime
PATH = sys.argv[1] if len(sys.argv) > 1 else "bundle.yaml"
DROP = {"admin", "internal", "shared-db", "amqp", "certificates", "cluster", "ha"}
KEEP = {'""', "public"}
VAULT_OPTS_EXPECTED = {"vip", "os-public-hostname"}
def abort(msg):
sys.stderr.write("ABORT (no changes written): %s\n" % msg)
sys.exit(1)
def indent_of(line):
return len(line) - len(line.lstrip())
if not os.path.isfile(PATH):
abort("file not found: %s (run from the repo root, or pass the path)" % PATH)
with open(PATH, "r", newline="") as fh:
orig = fh.readlines()
lines = list(orig)
# ---------- Edit 1: shrink &api-bindings ----------
anchor = next((i for i, ln in enumerate(lines)
if re.match(r'^api-bindings:\s*&api-bindings$', ln.strip())), None)
if anchor is None:
abort("could not locate 'api-bindings: &api-bindings'")
a_indent = indent_of(lines[anchor])
j, kept, dropped = anchor + 1, [], []
while j < len(lines):
raw = lines[j]
if raw.strip() == "" or indent_of(raw) <= a_indent:
break
key = raw.strip().split(":", 1)[0].strip()
(dropped if key in DROP else kept).append(key if key in DROP else raw)
j += 1
kept_keys = {l.strip().split(":", 1)[0].strip() for l in kept}
if kept_keys != KEEP or set(dropped) != DROP:
abort("api-bindings not in expected pre-fix shape (kept=%s dropped=%s)"
% (sorted(kept_keys), sorted(dropped)))
lines = lines[:anchor + 1] + kept + lines[j:]
# ---------- locate vault app block (post edit-1 indices) ----------
vault = next((i for i, ln in enumerate(lines)
if ln.strip() == "vault:" and indent_of(ln) == 2), None)
if vault is None:
abort("could not locate ' vault:' application block")
# block end = next line at indent <= 2 (non-blank)
vend = vault + 1
while vend < len(lines):
if lines[vend].strip() and indent_of(lines[vend]) <= 2:
break
vend += 1
# ---------- Edit 2: vault bindings -> internal-bindings ----------
b_fixed = False
for k in range(vault + 1, vend):
if re.match(r'^\s*bindings:\s*\*api-bindings\b', lines[k]):
lines[k] = lines[k].replace("*api-bindings", "*internal-bindings", 1)
b_fixed = True
break
if not b_fixed:
abort("vault 'bindings: *api-bindings' not found (already changed?)")
# ---------- Edit 3: remove vault options: block ----------
opt = next((k for k in range(vault + 1, vend)
if re.match(r'^\s{4}options:\s*$', lines[k])), None)
if opt is None:
abort("vault 'options:' line not found")
opt_indent = indent_of(lines[opt])
c = opt + 1
opt_children = []
while c < vend and lines[c].strip() and indent_of(lines[c]) > opt_indent:
opt_children.append(lines[c].strip().split(":", 1)[0].strip())
c += 1
if set(opt_children) != VAULT_OPTS_EXPECTED:
abort("vault options are %s, expected %s - inspect by hand (won't blind-delete)"
% (sorted(opt_children), sorted(VAULT_OPTS_EXPECTED)))
del lines[opt:c] # remove 'options:' + its children
# ---------- Edit 4: comment out the vault-hacluster subordinate ----------
hac = [i for i, ln in enumerate(lines)
if re.match(r'^\s*vault-hacluster:', ln) and not ln.lstrip().startswith("#")]
if len(hac) != 1:
abort("expected exactly 1 uncommented 'vault-hacluster:' line, found %d" % len(hac))
i = hac[0]
ind = indent_of(lines[i])
lines[i] = lines[i][:ind] + "# " + lines[i][ind:]
# ---------- Edit 5: comment out the vault:ha relation ----------
rel = [i for i, ln in enumerate(lines)
if ("vault:ha" in ln and "vault-hacluster:ha" in ln
and not ln.lstrip().startswith("#"))]
if len(rel) != 1:
abort("expected exactly 1 uncommented vault:ha relation, found %d" % len(rel))
i = rel[0]
ind = indent_of(lines[i])
lines[i] = lines[i][:ind] + "# " + lines[i][ind:]
# ---------- Backup + write ----------
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
bak = "%s.bak-%s" % (PATH, ts)
shutil.copy2(PATH, bak)
with open(PATH, "w", newline="") as fh:
fh.writelines(lines)
print("Backup written: %s" % bak)
print("=== unified diff ===")
sys.stdout.writelines(difflib.unified_diff(
orig, lines, fromfile="bundle.yaml (before)", tofile="bundle.yaml (after)"))
print("")
# ---------- Verify ----------
try:
import yaml
except Exception:
print("NOTE: PyYAML not importable - semantic verification skipped; re-verify on jumphost.")
sys.exit(0)
doc = yaml.safe_load(open(PATH))
apps = doc["applications"]
rels = doc.get("relations") or []
MIN = {"": "metal", "public": "provider"}
INT = {"": "metal"}
on_min = ["keystone", "ceph-radosgw", "openstack-dashboard", "octavia", "glance",
"nova-cloud-controller", "placement", "neutron-api", "cinder",
"barbican", "magnum"]
print("=== verification ===")
print("YAML parses: PASS")
ok = True
for a in on_min:
p = apps.get(a, {}).get("bindings") == MIN
ok &= p
print(" %-22s minimal api-bindings : %s" % (a, "PASS" if p else "FAIL"))
checks = [
("vault bindings == internal-bindings", apps.get("vault", {}).get("bindings") == INT),
("vault has no options block", apps.get("vault", {}).get("options") in (None, {})),
("vault-hacluster removed from apps", "vault-hacluster" not in apps),
("vault:ha relation removed", not any("vault:ha" in pair for pair in rels)),
]
for desc, p in checks:
ok &= p
print(" %-34s : %s" % (desc, "PASS" if p else "FAIL"))
nvip = sum(1 for ap in apps.values()
if isinstance(ap, dict) and isinstance(ap.get("options"), dict)
and str(ap["options"].get("vip", "")).startswith("10.12.4."))
pv = (nvip == 10)
ok &= pv
print(" %-34s : %s" % ("VIP count == 10 (was 11)", "PASS (%d)" % nvip if pv else "FAIL (%d)" % nvip))
print("\nRESULT:", "ALL CHECKS PASS"
if ok else "FAILURES - revert: cp %s %s" % (bak, PATH))
sys.exit(0 if ok else 2)