90 lines
3.6 KiB
Python
Executable File
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()
|