"""Generate `.env.example` and `docs/CONFIGURATION.md` from the typed settings. Hand-maintained configuration documentation drifts, and the operator discovers the drift when a production deployment does something the manual said it would not. Both files are generated from `Settings` joined with `SETTING_DOCS`, and a test fails when the committed files no longer match — so adding a setting without documenting it breaks the build rather than shipping quietly. python scripts/generate_configuration_docs.py # write both files python scripts/generate_configuration_docs.py --check # fail if they are stale """ from __future__ import annotations import argparse import sys from pathlib import Path, PurePath from types import UnionType from typing import Any, Literal, Union, get_args, get_origin ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "backend" / "src")) from modelforge_api.domain.configuration_reference import ( # noqa: E402 DEPLOYMENT_DOCS, SETTING_DOCS, Sensitivity, ) from modelforge_api.domain.release import ( # noqa: E402 MINIMUM_POSTGRES_MAJOR, PRODUCT_NAME, PRODUCT_VERSION, ) from modelforge_api.settings import Settings # noqa: E402 ENV_PREFIX = "MODELFORGE_" #: Values a generated example must never contain. The example is committed to the repository, so a #: real secret placed here would be published with it. EXAMPLE_SECRET_PLACEHOLDER = "" #: Settings a container sets for itself. Listing them in the example invites an operator to override #: a path that only makes sense inside the image. CONTAINER_MANAGED = frozenset( { "config_root", "alembic_directory", "build_commit", "build_timestamp", "build_image_digest", "agent_protocol_version", } ) def _default(name: str) -> str: field = Settings.model_fields[name] default: Any = field.default if default is None: return "" if isinstance(default, bool): return "true" if default else "false" if isinstance(default, PurePath): # These are paths *inside a Linux container*, so they must render with forward slashes # whatever platform generates the file. `str(Path("/data/backups"))` gives `\dataackups` # on Windows, and the committed .env.example shipped exactly that — telling operators to # point a Linux container at a Windows path. It also made the generated files differ by # platform, so the freshness check passed on one and failed on the other. return default.as_posix() if callable(default): # pragma: no cover - default_factory fields return "" return str(default) def _render_annotation(annotation: Any) -> str: """A stable, human-readable name for a setting's type. Deliberately not `str(annotation)`: Python 3.13 moved Path into `pathlib._local`, so the same field rendered as `pathlib.Path` on 3.12 and `pathlib._local.Path` on 3.13. The generated reference then differed by interpreter version and the freshness check failed on whichever machine had not produced it. """ simple = { "str": "string", "int": "integer", "float": "number", "bool": "boolean", "Path": "path", "PosixPath": "path", "WindowsPath": "path", "SecretStr": "secret", "NoneType": "none", } origin = get_origin(annotation) if origin is Literal: # Comma-separated, not pipe-separated: these land in a markdown table cell, and a pipe # there silently splits the row into extra columns. return ", ".join(f"`{value}`" for value in get_args(annotation)) if origin in (Union, UnionType): parts = [ _render_annotation(argument) for argument in get_args(annotation) if argument is not type(None) ] rendered = " or ".join(dict.fromkeys(parts)) return f"{rendered}, optional" if isinstance(annotation, type): return simple.get(annotation.__name__, annotation.__name__) return simple.get(str(annotation), str(annotation)) def _type_name(name: str) -> str: return _render_annotation(Settings.model_fields[name].annotation) def render_env_example() -> str: lines = [ f"# {PRODUCT_NAME} {PRODUCT_VERSION} — configuration example", "#", "# Generated by scripts/generate_configuration_docs.py. Do not edit by hand.", "# Copy to .env and fill in the values marked REQUIRED. See docs/CONFIGURATION.md.", "#", "# Secrets are intentionally empty here. This file is committed to the repository, so a", "# real value placed in it would be published with the release.", "", ] for name, doc in SETTING_DOCS.items(): if name in CONTAINER_MANAGED: continue marker = " (REQUIRED in production)" if doc.required_in_production else "" secret = doc.sensitivity is Sensitivity.SECRET lines.append(f"# {doc.description}{marker}") value = EXAMPLE_SECRET_PLACEHOLDER if secret else _default(name) lines.append(f"{ENV_PREFIX}{name.upper()}={value}") lines.append("") lines += [ "# --------------------------------------------------------------------------", "# Deployment variables. Read by Compose, the Node Agent and the Runtime Worker", "# rather than by the control-plane process - an operator still has to set them.", "# --------------------------------------------------------------------------", "", ] for name, doc in DEPLOYMENT_DOCS.items(): marker = " (REQUIRED in production)" if doc.required_in_production else "" lines.append(f"# {doc.description}{marker}") lines.append(f"{name}=") lines.append("") return "\n".join(lines).rstrip("\n") + "\n" def render_configuration_doc() -> str: required = [name for name, doc in SETTING_DOCS.items() if doc.required_in_production] secrets = [ name for name, doc in SETTING_DOCS.items() if doc.sensitivity is Sensitivity.SECRET ] lines = [ "# Configuration reference", "", f"Generated from the typed settings by `scripts/generate_configuration_docs.py` for " f"{PRODUCT_NAME} {PRODUCT_VERSION}. Every setting the control plane reads appears here; a " "setting added without documentation fails the build.", "", "All settings are environment variables with the `MODELFORGE_` prefix, read from the " "process environment or from `.env`.", "", "## Required in production", "", "`MODELFORGE_ENV=production` turns on fail-closed startup validation. With it set, the " "control plane refuses to start unless each of these is present and sound:", "", ] for name in required: lines.append(f"- `{ENV_PREFIX}{name.upper()}` — {SETTING_DOCS[name].description}") lines += [ "", "Production additionally refuses: a well-known development database password, a wildcard " "CORS origin, remote model code execution, an unwritable storage root, an unsupported " f"schema revision, a PostgreSQL major below {MINIMUM_POSTGRES_MAJOR}, and three policy " "combinations that cannot all hold at once.", "", "## Generating secrets", "", "ModelForge never mints its own credentials — a platform that generates its own admin " "secret has no way to tell you it did. Generate them yourself and store them outside the " "deployment:", "", "```bash", "# Operator API key (at least 32 characters)", "python -c \"import secrets; print(secrets.token_urlsafe(48))\"", "", "# Backup encryption key (base64 AES-256)", "python -c \"import base64, os; print(base64.b64encode(os.urandom(32)).decode())\"", "", "# Database password", "python -c \"import secrets; print(secrets.token_urlsafe(32))\"", "```", "", "Losing the backup encryption key makes every existing backup unrecoverable. It is the one " "value that must be stored somewhere the deployment cannot take down with it.", "", "## Sensitivity", "", f"{len(secrets)} settings are credentials. They are never logged, never written to a " "release artefact and never echoed in an error response:", "", ] for name in secrets: lines.append(f"- `{ENV_PREFIX}{name.upper()}`") lines += ["", "## Every setting", "", "| Variable | Type | Default | Required | Sensitivity | Description |", "| --- | --- | --- | --- | --- | --- |"] for name, doc in SETTING_DOCS.items(): default = _default(name) rendered = f"`{default}`" if default else "—" if doc.sensitivity is Sensitivity.SECRET: rendered = "—" lines.append( f"| `{ENV_PREFIX}{name.upper()}` | {_type_name(name)} | {rendered} | " f"{'yes' if doc.required_in_production else 'no'} | {doc.sensitivity} | " f"{doc.description} |" ) lines += [ "", "## Deployment variables", "", "Read by Compose, the Node Agent and the Runtime Worker rather than by the control-plane " "process. An operator still has to set them, so they are documented here too.", "", "| Variable | Required | Sensitivity | Description |", "| --- | --- | --- | --- |", ] for name, doc in DEPLOYMENT_DOCS.items(): lines.append( f"| `{name}` | {'yes' if doc.required_in_production else 'no'} | " f"{doc.sensitivity} | {doc.description} |" ) lines += [ "", "## Restarts", "", "Every setting is read at process start. Changing any of them requires restarting the " "control plane; none is re-read from the environment while the process is running.", "", ] return "\n".join(lines) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true") args = parser.parse_args(argv) targets = { ROOT / ".env.example": render_env_example(), ROOT / "docs" / "CONFIGURATION.md": render_configuration_doc(), } stale: list[str] = [] for path, content in targets.items(): current = path.read_text("utf-8") if path.is_file() else None if current == content: print(f" current {path.relative_to(ROOT)}") continue stale.append(str(path.relative_to(ROOT))) if not args.check: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") print(f" written {path.relative_to(ROOT)}") if args.check and stale: print(f"stale: {', '.join(stale)} — run scripts/generate_configuration_docs.py") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())