#!/usr/bin/env python3 """Enable Ananke's existing Vault-synchronized sudo path without replacing config.""" import argparse import os from pathlib import Path import re import shutil import stat import subprocess import tempfile import yaml def updated_config(source): """Preserve unrelated text/settings; change only credential lookup and Titan-24 user.""" config = yaml.safe_load(source) desired = { "host_sudo_secret_namespace": "maintenance", "host_sudo_secret_name_template": "ananke-sudo-{node}", "host_sudo_secret_password_key": "password", } for key in desired: source = re.sub(r"^ " + key + r":.*\n", "", source, flags=re.M) stanza = "".join(" " + key + ": '" + value + "'\n" for key, value in desired.items()) if source.count("\nstartup:\n") != 1 or source.count("\nssh_node_users:\n") != 1: raise ValueError("unexpected_config_structure") source = source.replace("\nstartup:\n", "\nstartup:\n" + stanza, 1) start = source.index("\nssh_node_users:\n") end_match = re.search(r"\n[^ #\n][^\n]*:", source[start + 1:]) if end_match is None: raise ValueError("missing_inventory_boundary") end = start + 1 + end_match.start() users = source[start:end] if not re.search(r"^ titan-24:", users, flags=re.M): users += "\n titan-24: tethys" else: users = re.sub(r"^ titan-24:.*", " titan-24: tethys", users, flags=re.M) source = source[:start] + users + source[end:] expected = config.copy() expected["startup"] = dict(config.get("startup", {}), **desired) expected["ssh_node_users"] = dict(config.get("ssh_node_users", {}), **{"titan-24": "tethys"}) if yaml.safe_load(source) != expected: raise ValueError("unrelated_configuration_change") return source def main(): """Validate staged configuration with Ananke, then retain a root-only rollback.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", type=Path, default=Path("/etc/ananke/ananke.yaml")) parser.add_argument("--apply", action="store_true") args = parser.parse_args() if os.geteuid() != 0: raise SystemExit("Run as root") source = args.config.read_text() original_stat = args.config.stat() revised = updated_config(source) if revised == source: print("Node-access configuration is current") return if not args.apply: print("Node-access configuration needs updating; use --apply") return fd, name = tempfile.mkstemp(prefix=".node-access-", dir=args.config.parent) staged = Path(name) try: with os.fdopen(fd, "w") as stream: stream.write(revised) checked = subprocess.run(["/usr/local/bin/ananke", "status", "--config", name], capture_output=True, timeout=45) if checked.returncode: raise SystemExit("Ananke rejected staged configuration; original retained") backup = Path("/var/lib/atlas-maintenance/ananke-access-before-20261004") backup.mkdir(parents=True, exist_ok=True, mode=0o700) target = backup / "ananke.yaml" if not target.exists(): shutil.copy2(args.config, target) target.chmod(0o600) os.chown(staged, original_stat.st_uid, original_stat.st_gid) staged.chmod(stat.S_IMODE(original_stat.st_mode)) staged.replace(args.config) print("Updated node access; restart Ananke after verifying synchronized secrets") finally: staged.unlink(missing_ok=True) if __name__ == "__main__": main()