from __future__ import annotations import os from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from urllib.parse import ParseResult, urlparse, urlunparse from uuid import uuid4 from app.core.crypto import generate_install_bundle _API_ROOT = Path(__file__).resolve().parents[2] _INSTALL_SECRETS_ROOT = Path(os.getenv("COMPTON_INSTALL_SECRETS_DIR", str(_API_ROOT / "data" / "secrets"))) INSTALL_SECRETS_DIR = _INSTALL_SECRETS_ROOT INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env" INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json" REQUIRED_KEYS = ( "POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "DATABASE_URL", "JWT_ACCESS_SECRET", "JWT_REFRESH_PEPPER", "S3_ACCESS_KEY", "S3_SECRET_KEY", "MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", ) @dataclass class InstallSecretsStatus: initialized: bool locked: bool path: str created: bool def _parse_env_text(raw: str) -> dict[str, str]: values: dict[str, str] = {} for line in raw.splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) values[key.strip()] = value.strip() return values def _render_env(values: dict[str, str]) -> str: ordered = [f"{key}={values[key]}" for key in sorted(values.keys())] return "\n".join(ordered) + "\n" def read_install_secrets() -> dict[str, str]: if not INSTALL_SECRETS_FILE.exists(): return {} return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8")) def _write_install_secrets(values: dict[str, str]) -> None: INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True) INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8") def _write_meta() -> None: payload = ( "{\n" f' "install_id": "{uuid4()}",\n' f' "locked_at": "{datetime.now(UTC).isoformat()}"\n' "}\n" ) INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8") def _is_postgres_database_url(database_url: str) -> bool: scheme = urlparse(database_url).scheme.lower() return scheme.startswith("postgresql") or scheme.startswith("postgres+") def _adopt_from_environment() -> dict[str, str]: env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS if key != "DATABASE_URL"} db_url = os.getenv("DATABASE_URL", "") if db_url and _is_postgres_database_url(db_url): parsed = urlparse(db_url) if parsed.username: env_values["POSTGRES_USER"] = parsed.username if parsed.password: env_values["POSTGRES_PASSWORD"] = parsed.password if parsed.path and parsed.path != "/": env_values["POSTGRES_DB"] = parsed.path.lstrip("/") env_values["DATABASE_URL"] = db_url return {key: value for key, value in env_values.items() if value} def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]: """MinIO root credentials are the S3 access key pair — keep them aligned.""" if ( values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER") and values.get("MINIO_ROOT_PASSWORD") and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"] ): values = dict(values) values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"] return values def load_install_secrets_to_env() -> None: values = _sync_minio_s3_secrets(read_install_secrets()) for key, value in values.items(): os.environ[key] = value def ensure_install_secrets() -> InstallSecretsStatus: existing = read_install_secrets() if existing: db_url = existing.get("DATABASE_URL", "") if db_url and not _is_postgres_database_url(db_url): raise RuntimeError( "install.env DATABASE_URL must be PostgreSQL (got SQLite). " "Remove apps/api/data/secrets/install.env and run: python apps/api/scripts/bootstrap_install.py" ) synced = _sync_minio_s3_secrets(existing) if synced != existing: synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true") _write_install_secrets(synced) existing = synced load_install_secrets_to_env() return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False) adopted = _adopt_from_environment() generated = generate_install_bundle() values = generated | adopted values["SECRETS_LOCKED"] = "true" _write_install_secrets(values) _write_meta() load_install_secrets_to_env() return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True) def masked_database_url(database_url: str) -> str: parsed = urlparse(database_url) if not parsed.username: return database_url password = "***" if parsed.password else "" credentials = f"{parsed.username}:{password}" if password else parsed.username host = parsed.hostname or "" if parsed.port: host = f"{host}:{parsed.port}" netloc = f"{credentials}@{host}" sanitized = ParseResult( scheme=parsed.scheme, netloc=netloc, path=parsed.path, params=parsed.params, query=parsed.query, fragment=parsed.fragment, ) return urlunparse(sanitized) def install_secrets_payload() -> dict: values = read_install_secrets() db = urlparse(values.get("DATABASE_URL", "")) return { "initialized": bool(values), "locked": values.get("SECRETS_LOCKED") == "true", "secrets_path": str(INSTALL_SECRETS_FILE), "database": { "host": db.hostname, "port": db.port, "database": db.path.lstrip("/") if db.path else "", "user": db.username, "password_configured": bool(values.get("POSTGRES_PASSWORD")), }, "connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")), "secrets_status": { "jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing", "jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing", "postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing", "s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing", "password_bcrypt_salt": "per_user_in_db", }, } def reveal_install_secret(key: str) -> str: mapping = { "database_password": "POSTGRES_PASSWORD", "jwt_access_secret": "JWT_ACCESS_SECRET", "jwt_refresh_pepper": "JWT_REFRESH_PEPPER", "s3_secret_key": "S3_SECRET_KEY", } env_key = mapping.get(key) if not env_key: raise ValueError("UNSUPPORTED_SECRET_KEY") values = read_install_secrets() if env_key in values: return values[env_key] if env_key == "POSTGRES_PASSWORD": database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "") parsed = urlparse(database_url) return parsed.password or "" return os.getenv(env_key, "")