115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
#!/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())
|