#!/usr/bin/env python3
"""
fix-bundle-haclusters.py - BUNDLEFIX-003
Add `options: { cluster_count: 1 }` to the 10 *active* testcloud haclusters so
the committed bundle matches the running model (we already set this at runtime
via `juju config`). Single-unit principals on the testcloud cannot form the
default 3-peer cluster; cluster_count=1 lets a 1-node cluster form and bring up
the (reachable, public->provider) VIP. Roosevelt's separate 3-unit bundle keeps
the default.
Text/line based - never round-trips YAML, so anchors/comments/formatting are
preserved. Only touches the named, *uncommented* hacluster lines; the commented
v2-deferred ones (vault-hacluster, ceph-radosgw-hacluster, designate-hacluster)
are left untouched. Idempotent: skips a line that already has cluster_count, and
aborts cleanly if nothing needs changing.
Usage: python3 fix-bundle-haclusters.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"
HACLUSTERS = ["keystone", "glance", "nova-cloud-controller", "neutron-api",
"cinder", "octavia", "barbican", "magnum", "placement",
"openstack-dashboard"]
INSERT_AFTER = "channel: 2.4/stable }"
INSERT_WITH = "channel: 2.4/stable, options: { cluster_count: 1 } }"
def abort(msg):
sys.stderr.write("ABORT (no changes written): %s\n" % msg)
sys.exit(1)
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)
changed = []
for name in HACLUSTERS:
# uncommented inline def line for this hacluster
pat = re.compile(r'^\s*%s-hacluster:\s*\{\s*charm:\s*hacluster' % re.escape(name))
hits = [i for i, ln in enumerate(lines)
if pat.match(ln) and not ln.lstrip().startswith("#")]
if len(hits) != 1:
abort("expected exactly 1 uncommented '%s-hacluster' inline def, found %d"
% (name, len(hits)))
i = hits[0]
if "cluster_count" in lines[i]:
abort("%s-hacluster already has cluster_count - already applied? inspect."
% name)
if INSERT_AFTER not in lines[i]:
abort("%s-hacluster line not in expected inline shape: %r"
% (name, lines[i].strip()))
lines[i] = lines[i].replace(INSERT_AFTER, INSERT_WITH, 1)
changed.append(name)
if len(changed) != len(HACLUSTERS):
abort("only changed %d of %d haclusters" % (len(changed), len(HACLUSTERS)))
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("")
try:
import yaml
except Exception:
print("NOTE: PyYAML not importable - semantic verification skipped; re-verify on jumphost.")
sys.exit(0)
apps = yaml.safe_load(open(PATH))["applications"]
print("=== verification ===")
print("YAML parses: PASS")
ok = True
for name in HACLUSTERS:
a = apps.get("%s-hacluster" % name, {})
cc = (a.get("options") or {}).get("cluster_count")
p = (cc == 1)
ok &= p
print(" %-30s cluster_count==1 : %s" % (name + "-hacluster", "PASS" if p else "FAIL (%r)" % cc))
# deferred ones must NOT have appeared
for absent in ("vault-hacluster", "ceph-radosgw-hacluster", "designate-hacluster"):
p = absent not in apps
ok &= p
print(" %-30s stays absent : %s" % (absent, "PASS" if p else "FAIL"))
print("\nRESULT:", "ALL CHECKS PASS"
if ok else "FAILURES - revert: cp %s %s" % (bak, PATH))
sys.exit(0 if ok else 2)