diff --git a/.coverage b/.coverage index f6d87fe..179c1d7 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.dockerignore b/.dockerignore index ca3c9c9..0d711f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,7 +9,6 @@ __pycache__/ .cursor/ *.plan.md data/ -.curosr/ # Tests and dev artifacts (not needed in image) tests/ .pytest_cache/ diff --git a/.gitignore b/.gitignore index 21f47da..6c3bfbf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ venv/ data/ *.db .cursor/ +# Test and coverage artifacts +.coverage +htmlcov/ +.pytest_cache/ +*.cover +*.plan.md diff --git a/Dockerfile b/Dockerfile index 31e2155..dfa32cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,16 +4,17 @@ # --- Stage 1: builder (dependencies only) --- FROM python:3.12-slim AS builder WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY pyproject.toml ./ +COPY duty_teller/ ./duty_teller/ +RUN pip install --no-cache-dir . # --- Stage 2: runtime (minimal final image) --- FROM python:3.12-slim WORKDIR /app -# Install gosu (drop privileges in entrypoint) -RUN apt-get update && apt-get install -y --no-install-recommends gosu \ +# Install gosu (drop privileges in entrypoint) and curl (for HEALTHCHECK) +RUN apt-get update && apt-get install -y --no-install-recommends gosu curl \ && rm -rf /var/lib/apt/lists/* # Copy installed packages and console scripts from builder (no requirements.txt, no pip layer) @@ -23,6 +24,7 @@ COPY --from=builder /usr/local/bin /usr/local/bin # Application code (duty_teller package + entrypoint, migrations, webapp) ENV PYTHONPATH=/app COPY main.py pyproject.toml entrypoint.sh ./ +RUN chmod +x entrypoint.sh COPY duty_teller/ ./duty_teller/ COPY alembic/ ./alembic/ COPY webapp/ ./webapp/ @@ -34,3 +36,6 @@ RUN adduser --disabled-password --gecos "" botuser \ # Entrypoint runs as root: fix /app/data ownership (for volume mount), run migrations, then exec as botuser ENTRYPOINT ["/bin/sh", "./entrypoint.sh"] CMD ["python", "main.py"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4fc86b8..8eda172 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,3 +1,5 @@ +version: "3.8" + services: bot: build: diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index f01a69c..6c0a13a 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -35,6 +35,14 @@ def _is_valid_calendar_token(token: str) -> bool: app = FastAPI(title="Duty Teller API") + + +@app.get("/health", summary="Health check") +def health() -> dict: + """Return 200 when the app is up. Used by Docker HEALTHCHECK.""" + return {"status": "ok"} + + app.add_middleware( CORSMiddleware, allow_origins=config.CORS_ORIGINS, diff --git a/entrypoint.sh b/entrypoint.sh index 6558967..bcfef04 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh -set -e +set -eu # Ensure data dir exists and is writable by botuser (volume may be root-owned) mkdir -p /app/data chown botuser:botuser /app/data diff --git a/requirements-dev.txt b/requirements-dev.txt index a9bb33e..2ddce6f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ -r requirements.txt pytest>=8.0,<9.0 pytest-asyncio>=1.0,<2.0 +pytest-cov>=6.0,<7.0 httpx>=0.27,<1.0 diff --git a/tests/test_app.py b/tests/test_app.py index 0da8233..14cc645 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,6 +16,13 @@ def client(): return TestClient(app) +def test_health(client): + """Health endpoint returns 200 and status ok for Docker HEALTHCHECK.""" + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + def test_duties_invalid_date_format(client): r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"}) assert r.status_code == 400