"""Docker container lifecycle management for the web app stack.""" from __future__ import annotations import logging import os import subprocess from pathlib import Path from typing import Annotated, Literal import typer logger = logging.getLogger(__name__) REPO_DIR = Path(__file__).resolve().parents[2] COMPOSE_FILE = REPO_DIR / "docker-compose.yml" EnvTarget = Literal["all", "web", "db"] REQUIRED_WORKOS_ENV_VARS = ( "WORKOS_API_KEY", "WORKOS_CLIENT_ID", "WORKOS_COOKIE_PASSWORD", "WORKOS_ORGANIZATION_ID", ) app = typer.Typer(help="Web stack container management.") def _compose_command(*args: str) -> list[str]: """Build a docker compose command for the repo-local stack.""" return ["docker", "compose", "-f", str(COMPOSE_FILE), *args] def _run_compose( *args: str, capture_output: bool = False, check: bool = True, ) -> subprocess.CompletedProcess[str]: """Run docker compose in the repository root.""" result = subprocess.run( _compose_command(*args), cwd=REPO_DIR, text=True, capture_output=capture_output, check=False, ) if check and result.returncode != 0: detail = result.stderr.strip() if result.stderr else f"exit code {result.returncode}" raise RuntimeError(f"docker compose {' '.join(args)} failed: {detail}") return result def _validate_workos_env() -> None: """Ensure the web app has the WorkOS env vars it needs before startup.""" missing = [name for name in REQUIRED_WORKOS_ENV_VARS if not os.getenv(name)] if missing: message = ( "Missing required WorkOS environment variables: " + ", ".join(missing) + ". Populate .env before running the web stack." ) raise RuntimeError(message) cookie_password = os.getenv("WORKOS_COOKIE_PASSWORD", "") if len(cookie_password) < 32: raise RuntimeError("WORKOS_COOKIE_PASSWORD must be at least 32 characters long.") def build_stack() -> None: """Build the web app image.""" logger.info("Building web image from %s", COMPOSE_FILE) _run_compose("build", "web", capture_output=False) logger.info("Web image built") def _validate_database_env() -> None: """Ensure the web app has the database env vars it needs before startup.""" required = ( "DATA_SCIENCE_DEV_DB", "DATA_SCIENCE_DEV_HOST", "DATA_SCIENCE_DEV_PORT", "DATA_SCIENCE_DEV_USER", ) missing = [name for name in required if not os.getenv(name)] if missing: message = ( "Missing required database environment variables: " + ", ".join(missing) + ". Populate .env before running the web stack." ) raise RuntimeError(message) def start_stack( *, build: bool = False, detach: bool = False, with_local_db: bool = False ) -> None: """Start the web stack, using the existing DB by default.""" _validate_workos_env() _validate_database_env() command = ["up"] if build: command.append("--build") if detach: command.append("-d") if with_local_db: command.extend(["--profile", "localdb", "db", "web"]) else: command.append("web") logger.info( "Starting web stack%s", " with local Postgres" if with_local_db else " against existing Postgres", ) _run_compose(*command, capture_output=False) def stop_stack(*, drop_volumes: bool = False) -> None: """Stop and remove the web stack.""" logger.info("Stopping web stack") command = ["down"] if drop_volumes: command.append("--volumes") _run_compose(*command, capture_output=False) def logs_stack(*, target: EnvTarget = "all", follow: bool = False, tail: int = 100) -> None: """Show docker compose logs for the web stack.""" command = ["logs", "--tail", str(tail)] if follow: command.append("--follow") if target != "all": command.append(target) _run_compose(*command, capture_output=False) @app.command() def build( log_level: Annotated[str, typer.Option(help="Log level")] = "INFO", ) -> None: """Build the web Docker image.""" logging.basicConfig( level=log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) build_stack() @app.command() def run( build: Annotated[ bool, typer.Option(help="Rebuild the web image before starting the stack") ] = False, detach: Annotated[ bool, typer.Option(help="Start the stack in the background") ] = False, with_local_db: Annotated[ bool, typer.Option(help="Also start the optional local Postgres container") ] = False, log_level: Annotated[str, typer.Option(help="Log level")] = "INFO", ) -> None: """Run the web + Postgres stack.""" logging.basicConfig( level=log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) start_stack(build=build, detach=detach, with_local_db=with_local_db) @app.command() def stop( drop_volumes: Annotated[ bool, typer.Option(help="Also delete the Postgres volume") ] = False, ) -> None: """Stop and remove the web stack.""" stop_stack(drop_volumes=drop_volumes) @app.command() def logs( target: Annotated[ EnvTarget, typer.Option(help="Which service logs to show") ] = "all", follow: Annotated[ bool, typer.Option(help="Follow logs until interrupted") ] = False, tail: Annotated[int, typer.Option(help="How many recent lines to show")] = 100, ) -> None: """Show recent logs from the web stack.""" logs_stack(target=target, follow=follow, tail=tail) def cli() -> None: """Typer entry point.""" app() if __name__ == "__main__": cli()