# steward/config.py from __future__ import annotations import logging import os import secrets from pathlib import Path from typing import Any logger = logging.getLogger(__name__) _SECRET_KEY_FILE = Path("/data/secret.key") def _env(suffix: str) -> str | None: return os.environ.get(f"STEWARD_{suffix}") def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]: """Return the minimum bootstrap config: database_url and secret_key. This is the only config read from files/env vars at startup. Everything else is stored in the app_settings DB table. config_path is optional — used only for backwards compatibility with existing config.yaml deployments. The file is not required. """ from dotenv import load_dotenv load_dotenv() raw: dict[str, Any] = {} if config_path is None: config_path = Path("config.yaml") config_path = Path(config_path) if config_path.exists(): import yaml with config_path.open() as f: raw = yaml.safe_load(f) or {} database_url = ( _env("DATABASE_URL") or _env("DATABASE__URL") or raw.get("database", {}).get("url") ) if not database_url: raise ValueError( "Database URL is required. Set STEWARD_DATABASE_URL env var " "or add 'database.url' to config.yaml." ) secret_key = _resolve_secret_key(raw) # Plugin discovery spans two roots (see load_plugins / migration_runner): # • bundled — first-party plugins shipped inside the image at repo-root # `plugins/`; they version atomically with core and are read-only at runtime. # • external — operator-mounted dir for third-party plugins, persisted in the # /data volume. Downloads/installs land here, never in the bundled dir. # Bundled is scanned first, so on a name collision the first-party plugin wins. bundled_plugin_dir = raw.get("plugin_dir", "plugins") external_plugin_dir = ( _env("PLUGIN_DIR") or raw.get("external_plugin_dir") or "/data/plugins" ) plugin_dirs = [bundled_plugin_dir] if external_plugin_dir and external_plugin_dir != bundled_plugin_dir: plugin_dirs.append(external_plugin_dir) return { "database_url": database_url, "secret_key": secret_key, "plugin_dirs": plugin_dirs, # Installs/downloads target the external (writable, persistent) dir. "plugin_install_dir": external_plugin_dir or bundled_plugin_dir, } def _resolve_secret_key(raw: dict) -> str: """Resolve secret_key: env var → file → auto-generate. Refuses to start if a new key must be generated but cannot be persisted: an ephemeral key changes on every restart, which silently renders every encrypted secret (managed SSH key, SMTP/OIDC/LDAP credentials) unrecoverable. Failing loudly with a fix beats limping along and losing data on the next boot — exactly the footgun that bit the vdnt-docker02 deployment. """ from_env = _env("SECRET_KEY") or raw.get("secret_key") if from_env: return from_env if _SECRET_KEY_FILE.exists(): try: key = _SECRET_KEY_FILE.read_text().strip() except OSError as exc: raise RuntimeError( f"App secret key file {_SECRET_KEY_FILE} exists but cannot be read " f"({exc}). Make it readable by the container user (uid 1000), or set " f"STEWARD_SECRET_KEY." ) from exc if key: return key key = secrets.token_hex(32) try: _SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) _SECRET_KEY_FILE.write_text(key) except OSError as exc: raise RuntimeError( f"Generated a new app secret key but could not persist it to " f"{_SECRET_KEY_FILE} ({exc}). An ephemeral key changes on every restart, " f"which makes all encrypted secrets (managed SSH key, SMTP/OIDC/LDAP " f"credentials) unrecoverable. Fix one of:\n" f" • set STEWARD_SECRET_KEY to a stable value " f"(recommended for Swarm / multi-node), or\n" f" • make {_SECRET_KEY_FILE.parent} writable by the container user " f"(uid 1000)." ) from exc logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE) return key