198 lines
5.7 KiB
Python
198 lines
5.7 KiB
Python
"""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()
|