#!/usr/bin/env python3 """Synchronize VM2 runtime secrets, then execute a command with paths only.""" from __future__ import annotations import argparse import json import os import subprocess import sys import tempfile from pathlib import Path from secrets_loader import LoaderError, load_json, run def public_config(path: Path) -> dict[str, str]: result: dict[str, str] = {} for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): line = raw.strip() if not line or line.startswith("#"): continue if "=" not in line: raise LoaderError(f"invalid non-secret config at line {number}") key, value = line.split("=", 1) if not key or key in result: raise LoaderError(f"invalid/duplicate non-secret key at line {number}") result[key] = value return result def selected_config(public: dict[str, str], explicit: Path | None) -> tuple[str, Path]: source = public.get("SECRETS_SOURCE") if source not in {"selectel", "file"}: raise LoaderError("SECRETS_SOURCE must explicitly be selectel or file") if explicit: return source, explicit environment = public.get("APP_ENV", "production") return source, Path(f"/etc/han/secrets/vm2-{environment}.{source}.json") def prepare(config_path: Path, source: str, synchronize: bool) -> dict[str, str]: document = load_json(config_path) if document.get("mode") != source: raise LoaderError("loader mode does not match SECRETS_SOURCE") runtime = Path(str(document.get("runtime_dir", ""))) state_path = runtime / "state.json" if synchronize: run(config_path) descriptor, temporary = tempfile.mkstemp(prefix=".state.", dir=runtime) with os.fdopen(descriptor, "w", encoding="utf-8") as stream: json.dump( {"version": 1, "source": source, "loader_config": str(config_path.resolve())}, stream, separators=(",", ":"), ) stream.write("\n") stream.flush() os.fsync(stream.fileno()) os.chmod(temporary, 0o600) os.replace(temporary, state_path) if not state_path.is_file(): raise LoaderError("runtime secrets are not synchronized") state = load_json(state_path) if state != { "version": 1, "source": source, "loader_config": str(config_path.resolve()), }: raise LoaderError("runtime secret state does not match selected configuration") manifest = runtime / "manifest" entries: dict[str, str] = {} for line in manifest.read_text(encoding="utf-8").splitlines(): key, separator, value = line.partition("=") if not separator or key in entries or not Path(value).is_file(): raise LoaderError("runtime secret manifest is invalid") entries[key] = value if set(entries) != set(document.get("secrets", {})): raise LoaderError("runtime secret manifest does not match configuration") child = dict(os.environ) child["HAN_SECRETS_ACTIVE"] = "1" child["HAN_RUNTIME_SECRET_DIR"] = str(runtime) child["HAN_RUNTIME_SECRET_MANIFEST"] = str(manifest) for key, value in entries.items(): child[f"{key}_FILE"] = value return child def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("action", choices=("sync", "run")) parser.add_argument("--config", type=Path, default=Path(".env")) parser.add_argument("--loader-config", type=Path) arguments, command = parser.parse_known_args() if command and command[0] == "--": command.pop(0) if arguments.action == "run" and not command: parser.error("run requires a command after --") try: source, config = selected_config(public_config(arguments.config), arguments.loader_config) child = prepare(config, source, arguments.action == "sync") except (LoaderError, OSError, ValueError, json.JSONDecodeError) as exc: print(f"han-secrets-vm2: {exc}", file=sys.stderr) return 1 if arguments.action == "sync": return 0 if os.name == "nt": return subprocess.call(command, env=child) os.execvpe(command[0], command, child) return 127 if __name__ == "__main__": raise SystemExit(main())