setup workos

This commit is contained in:
2026-05-02 20:57:09 -04:00
parent 45bdd7b629
commit a956c4a973
20 changed files with 1286 additions and 142 deletions
+197
View File
@@ -0,0 +1,197 @@
"""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()