#!/usr/bin/env python3 """Synchronize runtime secrets and execute a command without exporting their values.""" 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 load_public_config(path: Path) -> dict[str, str]: values: dict[str, str] = {} try: lines = path.read_text(encoding="utf-8").splitlines() except OSError as exc: raise LoaderError( f"cannot read non-secret config: {exc.strerror or exc.__class__.__name__}" ) from None for number, raw in enumerate(lines, 1): line = raw.strip() if not line or line.startswith("#"): continue if "=" not in line: raise LoaderError(f"non-secret config has invalid syntax at line {number}") key, value = line.split("=", 1) key = key.strip() if not key or key in values: raise LoaderError(f"non-secret config has an invalid key at line {number}") values[key] = value.strip() return values def loader_config_path( public: dict[str, str], explicit: Path | None, environ: dict[str, str], ) -> 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 is not None: return source, explicit override = environ.get(f"HAN_SECRETS_{source.upper()}_CONFIG") if override: return source, Path(override) environment = public.get("APP_ENV", "production") return source, Path(f"/etc/han/secrets/{environment}.{source}.json") def prepare_environment( config_path: Path, source: str, environ: dict[str, str], *, synchronize: bool, ) -> dict[str, str]: document = load_json(config_path) if document.get("mode") != source: raise LoaderError("selected loader configuration mode does not match SECRETS_SOURCE") runtime = document.get("runtime_dir") if not isinstance(runtime, str) or not Path(runtime).is_absolute(): raise LoaderError("loader configuration has an invalid runtime_dir") runtime_dir = Path(runtime) manifest = runtime_dir / "manifest" state_path = runtime_dir / "state.json" consumers: list[str] = [] if synchronize or (source == "file" and not state_path.is_file()): consumers = run(config_path, environ=environ) state = { "version": 1, "source": source, "loader_config": str(config_path.resolve()), } descriptor, temporary = tempfile.mkstemp( prefix=".state.", suffix=".tmp", dir=runtime_dir ) temporary_path = Path(temporary) try: os.chmod(temporary_path, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as stream: descriptor = -1 json.dump(state, stream, separators=(",", ":")) stream.write("\n") stream.flush() os.fsync(stream.fileno()) os.replace(temporary_path, state_path) except BaseException: if descriptor >= 0: os.close(descriptor) temporary_path.unlink(missing_ok=True) raise elif not state_path.is_file(): raise LoaderError( "runtime secrets are not synchronized; restart han-secrets systemd unit" ) else: state = load_json(state_path) if ( state.get("version") != 1 or state.get("source") != source or state.get("loader_config") != str(config_path.resolve()) ): raise LoaderError( "runtime secret state does not match selected source/config; synchronize first" ) if not manifest.is_file(): raise LoaderError("runtime secret manifest was not materialized") manifest_entries: dict[str, str] = {} for line in manifest.read_text(encoding="utf-8").splitlines(): key, value_path = line.split("=", 1) manifest_entries[key] = value_path specs = document.get("secrets") if not isinstance(specs, dict) or set(manifest_entries) != set(specs): raise LoaderError("runtime secret manifest does not match loader configuration") child = dict(environ) child["HAN_SECRETS_ACTIVE"] = "1" child["HAN_RUNTIME_SECRET_DIR"] = str(runtime_dir) child["HAN_RUNTIME_SECRET_MANIFEST"] = str(manifest) for key, value_path in manifest_entries.items(): if not Path(value_path).is_file(): raise LoaderError("runtime secret manifest references a missing file") child[f"{key}_FILE"] = value_path if synchronize or consumers: print( f"han-secrets: synchronized {len(consumers)} service scope(s) from {source}", file=sys.stderr, ) return child def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) 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(argv) if command and command[0] == "--": command.pop(0) if arguments.action == "run" and not command: parser.error("run requires a command after --") if arguments.action == "sync" and command: parser.error("sync does not accept a command") environment = dict(os.environ) try: public = load_public_config(arguments.config) source, loader_config = loader_config_path( public, arguments.loader_config, environment ) child = prepare_environment( loader_config, source, environment, synchronize=arguments.action == "sync", ) except (LoaderError, OSError, ValueError, json.JSONDecodeError) as exc: message = str(exc) if isinstance(exc, LoaderError) else exc.__class__.__name__ print(f"han-secrets: {message}", 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())