atlas-iac/scripts/configure_ananke_node_access.py

90 lines
3.6 KiB
Python
Executable File

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