Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Build context is the repository root (the uv workspace lives here). Only
# the API's runtime files are copied into the image; keep everything else,
# and every secret, out of the context.
**/.env
**/.env.*
!**/.env.example
**/node_modules
**/.venv
**/__pycache__
**/*.pyc
**/.mypy_cache
**/.pytest_cache
**/.ruff_cache
**/.coverage
**/.next
**/test-results
**/playwright-report
.git
.github
apps
docs
infra
services/api/tests
services/api/eval
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Shell scripts run inside Linux containers; CRLF breaks /bin/sh.
*.sh text eol=lf
Dockerfile text eol=lf
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,48 @@ jobs:
uv export --frozen --no-dev --no-hashes --no-emit-workspace > "$RUNNER_TEMP/requirements.txt"
uvx pip-audit -r "$RUNNER_TEMP/requirements.txt" --no-deps --disable-pip --progress-spinner off

image:
name: API image (build, smoke, scan)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v4
- name: Build
uses: docker/build-push-action@v7
with:
context: .
file: services/api/Dockerfile
tags: messfit-api:ci
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke test (API answers and reports healthy, as non-root)
run: |
docker run -d --name api -p 8000:8000 \
-e DATABASE_URL=postgresql+asyncpg://u:p@localhost:5432/none \
-e REDIS_URL=redis://localhost:6379/0 \
-e SUPABASE_URL=http://localhost:54321 -e SUPABASE_PROJECT_REF=ci \
-e SUPABASE_JWT_SECRET=ci-test-secret-ci-test-secret-ci-test \
-e GEMINI_API_KEY=ci-dummy \
messfit-api:ci
for i in $(seq 1 30); do curl -fsS localhost:8000/health && break; sleep 1; done
curl -fsS localhost:8000/health | grep -q '"status":"ok"'
test "$(docker exec api id -u)" != "0"
for i in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' api)
[ "$status" = healthy ] && break; sleep 2
done
test "$status" = healthy
- name: Container logs
if: failure()
run: docker logs api || true
- name: Vulnerability scan (fail on fixable HIGH/CRITICAL)
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.75.0 \
image --quiet --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
--exit-code 1 messfit-api:ci

web:
name: Web (types, lint, build, e2e)
runs-on: ubuntu-latest
Expand Down
39 changes: 38 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# docker-compose.yml
#
# docker compose up -d Postgres + Redis for local development
# docker compose --profile app up -d ...plus the API, worker and beat, built
# from services/api/Dockerfile and
# configured by services/api/.env
services:
postgres:
image: pgvector/pgvector:pg16
Expand All @@ -16,5 +21,37 @@ services:
ports:
- "6379:6379"

api:
profiles: ["app"]
build:
context: .
dockerfile: services/api/Dockerfile
image: messfit-api:local
command: ["api"]
env_file: services/api/.env
environment:
REDIS_URL: redis://redis:6379/0
ports:
- "8000:8000"
depends_on: [redis]

worker:
profiles: ["app"]
image: messfit-api:local
command: ["worker"]
env_file: services/api/.env
environment:
REDIS_URL: redis://redis:6379/0
depends_on: [redis, api]

beat:
profiles: ["app"]
image: messfit-api:local
command: ["beat"]
env_file: services/api/.env
environment:
REDIS_URL: redis://redis:6379/0
depends_on: [redis, api]

volumes:
pgdata:
pgdata:
30 changes: 29 additions & 1 deletion docs/runbooks/deploy-rollback.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@
`alembic upgrade head` on release (or run it manually — see below).
- Commits fast-forward to `main` linearly (no force pushes).

### The backend image
One image, built from the repository root by `services/api/Dockerfile`, runs
every backend process. The container's first argument picks the role
(`services/api/deploy/entrypoint.sh`):

| Service on the host | Type | Command | Notes |
|---|---|---|---|
| `messfit-api` | web service | `api` | Health check path `/health`. Honours `PORT` and `WEB_CONCURRENCY`. |
| `messfit-worker` | background worker | `worker` | Menu OCR and other jobs. Scale horizontally. |
| `messfit-beat` | background worker | `beat` | The schedule. **Exactly one instance**, or jobs run twice. |
| release step | pre-deploy command | `migrate` | Runs `alembic upgrade head` before new code takes traffic. |

All three services share the API's environment (see
`services/api/.env.example`). The app's `DATABASE_URL` is the
least-privilege `messfit_app` role (the worker and beat may use
`messfit_worker`); set `MIGRATION_DATABASE_URL` to the schema owner
(`postgres`) so only the release step can change the schema.

Build and run it locally:
```bash
docker build -f services/api/Dockerfile -t messfit-api .
docker compose --profile app up -d # API, worker and beat from the image
```
CI builds the image on every pull request, checks it starts healthy as a
non-root user, and fails on any fixable HIGH or CRITICAL vulnerability
(Trivy).

### Migrations
Run before/with the API release so the schema matches the code:
```bash
Expand All @@ -26,7 +53,8 @@ stays compatible with the new schema.
4. Verify `/health` reports the expected version + a smoke request passes.

## Pre-deploy checklist
- [ ] CI green (ruff, mypy, pytest; tsc + next build).
- [ ] CI green: API (ruff, format, mypy, migrations, pytest, eval, audit),
image (build, smoke, scan) and web (tsc, eslint, build, e2e, audit).
- [ ] New migration has a tested `downgrade`.
- [ ] Env vars for any new settings are set on the host (see
`rotate-secrets.md` for the list).
Expand Down
64 changes: 64 additions & 0 deletions services/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1.7
#
# One image for every backend process: the API, the Celery worker, Celery
# beat and the migration job. The role is the container's first argument
# (see deploy/entrypoint.sh). Build from the repository root, because the uv
# workspace and its lockfile live there:
#
# docker build -f services/api/Dockerfile -t messfit-api .
# docker run --env-file services/api/.env -p 8000:8000 messfit-api api

ARG PYTHON_VERSION=3.12
ARG UV_VERSION=0.11.7

FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv

# ── build: resolve the locked runtime dependencies into a venv ─────────────
FROM python:${PYTHON_VERSION}-slim-bookworm AS build
COPY --from=uv /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/opt/venv
WORKDIR /src
# Dependencies first, so source edits don't invalidate this layer.
COPY pyproject.toml uv.lock ./
COPY services/api/pyproject.toml services/api/pyproject.toml
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --package messfit-api --no-install-workspace

# ── runtime: slim, non-root, no build tools ────────────────────────────────
FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime
LABEL org.opencontainers.image.title="messfit-api" \
org.opencontainers.image.source="https://github.com/IDEA-Amrita/Mess-Fit" \
org.opencontainers.image.licenses="Apache-2.0"

# Pick up Debian security fixes released since the base image was published.
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*

RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app

ENV PATH=/opt/venv/bin:$PATH \
PYTHONPATH=/app \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PORT=8000

COPY --from=build /opt/venv /opt/venv
WORKDIR /app
COPY --chown=app:app services/api/alembic.ini ./
COPY --chown=app:app services/api/infra/migrations ./infra/migrations
COPY --chown=app:app services/api/scripts ./scripts
COPY --chown=app:app services/api/messfit_api ./messfit_api
COPY --chown=app:app --chmod=755 services/api/deploy/entrypoint.sh /usr/local/bin/entrypoint

USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://127.0.0.1:{os.environ.get(\"PORT\", \"8000\")}/health', timeout=3)" || exit 1

ENTRYPOINT ["entrypoint"]
CMD ["api"]
48 changes: 48 additions & 0 deletions services/api/deploy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/bin/sh
# Container entrypoint: the first argument picks the process.
#
# api HTTP API (uvicorn). PORT and WEB_CONCURRENCY are honoured.
# worker Celery worker: menu OCR and other background jobs.
# beat Celery beat: the daily and weekly schedule. Run exactly one.
# migrate alembic upgrade head as the schema owner (postgres), taken from
# MIGRATION_DATABASE_URL when set, else DATABASE_URL. Never the
# least-privilege messfit_app role.
#
# Anything else is executed as given, e.g. `python scripts/ingest_articles.py`.
set -eu

role="${1:-api}"
[ "$#" -gt 0 ] && shift

case "$role" in
api)
# Behind the platform's load balancer: trust its X-Forwarded-* headers so
# rate limits and logs see the client's address, not the proxy's.
exec uvicorn messfit_api.main:app \
--host 0.0.0.0 --port "${PORT:-8000}" \
--workers "${WEB_CONCURRENCY:-2}" \
--proxy-headers --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
--no-server-header "$@"
;;
worker)
exec celery -A messfit_api.celery_app worker \
--loglevel "${LOG_LEVEL:-info}" --concurrency "${CELERY_CONCURRENCY:-2}" "$@"
;;
beat)
# The schedule file lives in /tmp: the app user can't write to /app.
exec celery -A messfit_api.celery_app beat \
--loglevel "${LOG_LEVEL:-info}" --schedule /tmp/celerybeat-schedule "$@"
;;
migrate)
# Hosts usually give the release step the service's own environment, so a
# separate owner URL can be supplied without exposing it to the app.
if [ -n "${MIGRATION_DATABASE_URL:-}" ]; then
DATABASE_URL="$MIGRATION_DATABASE_URL"
export DATABASE_URL
fi
exec alembic upgrade head "$@"
;;
*)
exec "$role" "$@"
;;
esac
Loading