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
28 changes: 28 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Downloaded issuer PDFs and the corpus stay on the machine that built them
# (ADR 0016). Nothing in the image needs them, and the image must never carry
# them.
data/

.venv/
logs/
frontend/
tests/
scripts/
docs/
.playwright-mcp/
.git/
.github/
.claude/
.ruff_cache/
.pytest_cache/
.coverage
**/__pycache__/
**/*.py[cod]
.env
.env.*
*.md
# pyproject.toml declares it as the package readme, so hatchling needs it to
# build the project itself.
!README.md
Makefile
docker-compose.yml
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy to .env and fill in. Never commit .env.
# Every setting and its default is in src/policypal/config.py; only the three
# with no default are required.

# --- Required ---
DATABASE_URL=postgresql+psycopg://policypal:password@localhost:5432/policypal
OPENAI_API_KEY=
# A long random string, e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`
JWT_SECRET_KEY=

# --- Optional ---
# Live plan prices come from the CMS Marketplace API. Without a key, plan
# search still works from the stored catalog; the prices are the stored
# age-27 ones rather than the user's age.
CMS_MARKETPLACE_API_KEY=

# "production" switches logs to JSON on stdout as well as to a file.
ENVIRONMENT=development
LOG_LEVEL=INFO
LOG_DIR=logs/backend

# Where the browser app is served from. CORS allows this origin and no other.
FRONTEND_ORIGIN=http://localhost:5173
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,52 @@ jobs:
- name: Dependency audit
run: npm audit --audit-level=high

# Closes the first of ADR 0002's two deferred items. The image is built and
# scanned on every PR; pushing it anywhere is the deploy workflow's job.
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1

- name: Build the API image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
load: true
tags: policypal-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

# The PDFs and the corpus are local-only (ADR 0016) and .dockerignore
# excludes data/. A regression there would ship gigabytes of issuer
# documents, so it is checked rather than trusted.
- name: The image must carry no issuer documents
env:
IMAGE: policypal-api:${{ github.sha }}
run: |
set -euo pipefail
found=$(docker run --rm --entrypoint sh "$IMAGE" -c 'ls -A /app/data 2>/dev/null | wc -l')
test "$found" -eq 0 || { echo "data/ reached the image"; exit 1; }

# Trivy's own action installs its binary through a script that fails
# silently on the runner; its published image is pinned instead, the
# same way gitleaks is. `--input` on a saved tar means no docker socket
# has to be handed to the scanner.
- name: Scan the image
env:
IMAGE: policypal-api:${{ github.sha }}
# Pinned by digest: a tag can be moved to different bytes.
TRIVY: "aquasec/trivy:0.64.1@sha256:a8ca29078522f30393bdb34225e4c0994d38f37083be81a42da3a2a7e1488e9e"
run: |
set -euo pipefail
docker save "$IMAGE" -o "$RUNNER_TEMP/image.tar"
docker run --rm -v "$RUNNER_TEMP:/scan" "$TRIVY" image \
--input /scan/image.tar \
--format table --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL
rm -f "$RUNNER_TEMP/image.tar"

secret-scan:
runs-on: ubuntu-latest
env:
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

### Added

- A `Dockerfile` for the API, and a CI job that builds and scans the image
(ADR 0022) — the first of ADR 0002's two deferred items. Multi-stage, runs
as a non-root user, `torch` from PyTorch's CPU index on Linux so no CUDA
libraries ship, and both models baked in at their pinned revisions so a cold
start needs no HuggingFace call. CI runs the built image and fails if
`/app/data` is not empty: the issuer PDFs are local-only (ADR 0016) and a
`.dockerignore` regression would otherwise ship gigabytes of them unnoticed.
Trivy fails the build on fixable HIGH or CRITICAL findings.
- `.env.example`, which did not exist: the three settings with no default
(`DATABASE_URL`, `OPENAI_API_KEY`, `JWT_SECRET_KEY`) and the handful worth
setting, with no values in it.
- `make test`, `make lint` and `make check` run what CI runs, so passing
locally means the same thing.
- Phase 4 live ingest: the catalog and SBCs for eighteen HealthCare.gov states
— 3,276 plans from 137 issuers, every issuer's documents rather than the top
parents'. 1,246 documents are stored and 1,940 plans (59.2%) have text behind
Expand Down Expand Up @@ -201,6 +214,13 @@

### Changed

- In production the logs also go to stdout as JSON, where a container platform
collects them; a container's own filesystem is ephemeral and unread.
Development is unchanged, so an ingestion run's log lines do not interleave
with its progress output.
- `tiktoken` and `anyio` are no longer dependencies. Neither was imported
anywhere; `tiktoken` in particular sat next to `core/text.py`'s deliberate
1.35×-word-count approximation looking like an unfinished intention.
- The BM25 index is stored in the database, in `search_indexes`, instead of
`data/corpus/indices/bm25.pkl` (ADR 0021, new migration). A missing index
was a `FileNotFoundError` nothing caught, so an app running anywhere but the
Expand Down
71 changes: 71 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# The API only. Ingestion stays on the machine that holds data/ (ADR 0016,
# 0019); nothing in this image fetches or keeps a PDF.
FROM python:3.12-slim-bookworm@sha256:392307d22300de8b5986851a12d9176dfc0fc073e65bf6523ebd7dcbeb23564e AS builder

# Every base and tool image is pinned by digest, not by tag: a tag can be
# moved to different bytes, and the rest of this repo pins actions by SHA
# and gitleaks by checksum for the same reason.
COPY --from=ghcr.io/astral-sh/uv:0.11.23@sha256:d0a0a753ab981624b49c97abc98821c1c09f4ca69d1ef5cee69c501be3d88479 /uv /bin/uv

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never

WORKDIR /app

# Dependencies resolve from the lock alone, so this layer is rebuilt only when
# the lock changes — not on every source edit. torch comes from PyTorch's CPU
# index on linux (see pyproject.toml), which leaves out the CUDA libraries.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project

COPY src/ ./src/
COPY migrations/ ./migrations/
COPY alembic.ini README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev

# Both models, at the revisions config.py pins, downloaded once here. A cold
# start then loads them from the image instead of reaching HuggingFace, so a
# HuggingFace outage cannot take the API down and no request waits on a
# 220 MB download.
# The venv's python directly, not `uv run`: `uv run` re-syncs the default
# groups, which pulls the dev dependencies back in after --no-dev removed them.
ENV HF_HOME=/opt/models
RUN JWT_SECRET_KEY=build OPENAI_API_KEY=build DATABASE_URL=postgresql+psycopg://build/build \
/app/.venv/bin/python -c "\
from sentence_transformers import CrossEncoder, SentenceTransformer; \
from src.policypal.config import settings; \
SentenceTransformer(settings.embedding_model, revision=settings.embedding_model_revision); \
CrossEncoder(settings.reranker_model, revision=settings.reranker_model_revision)"


FROM python:3.12-slim-bookworm@sha256:392307d22300de8b5986851a12d9176dfc0fc073e65bf6523ebd7dcbeb23564e AS runtime

# Nothing here runs as root, and nothing writes to the image.
RUN useradd --create-home --uid 10001 policypal

ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HF_HOME=/opt/models \
HF_HUB_OFFLINE=1 \
LOG_DIR=/tmp/logs

WORKDIR /app

COPY --from=builder --chown=policypal:policypal /app/.venv /app/.venv
COPY --from=builder --chown=policypal:policypal /opt/models /opt/models
COPY --chown=policypal:policypal src/ ./src/
COPY --chown=policypal:policypal migrations/ ./migrations/
COPY --chown=policypal:policypal alembic.ini ./

USER policypal
EXPOSE 8000

# One worker: Flask-Limiter has no shared storage yet, so each worker would
# keep its own counters and the configured limits would multiply (ADR 0022).
# Threads carry the concurrency; the request path waits on OpenAI, not on CPU.
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "8", \
"--timeout", "120", "--access-logfile", "-", "src.api.main:create_app()"]
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,34 @@ driven from the repo root:
| `make ui-test` | Vitest |
| `make ui-preview` | serves `frontend/dist` — run `make ui-build` first |

Copy the required variables below into a `.env` file at the repo root before running the API.
And what CI runs, in one command each:

| Target | Runs |
| --- | --- |
| `make test` | pytest with the 85% coverage floor |
| `make lint` | ruff |
| `make check` | lint, tests, `alembic check`, bandit, pip-audit |

Copy `.env.example` to `.env` at the repo root and fill in the three required values
before running the API.

### Running the API in a container

```bash
docker build -t policypal-api .
docker run --rm -p 8000:8000 --env-file .env -e ENVIRONMENT=production policypal-api
```

The image carries the API only — ingestion, refresh and the reports stay on the machine
that holds `data/` (ADR 0016, 0019), and `.dockerignore` keeps the issuer PDFs out of it.
It needs no `data/` directory at all: the BM25 index comes from the database (ADR 0021)
and both models are baked in at their pinned revisions, so a cold start makes no
HuggingFace call. `torch` comes from PyTorch's CPU index on Linux, so no CUDA libraries
ship (ADR 0022).

It runs one gunicorn worker with eight threads. That is a deliberate ceiling, not a
default: Flask-Limiter has no shared storage yet, so a second worker would keep its own
counters and double the configured rate limits.

> **macOS note:** AirPlay Receiver (Control Center) listens on `*:5000`. Flask still binds
> `127.0.0.1:5000` alongside it, but `localhost` can resolve to `::1` and reach AirPlay instead —
Expand Down
119 changes: 119 additions & 0 deletions docs/decisions/0022-the-api-as-a-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 0022 — The API as a container

## Status

Accepted. Closes the first of ADR 0002's two deferred items ("Docker build +
image vulnerability scan. There is no Dockerfile for the app."). Depends on
ADR 0021, without which the image cannot serve a request.

## Context

The app has only ever run on the machine that built its corpus. Everything
that assumption hid had to be found before an image could work: ADR 0021 moved
the BM25 index off local disk, and the API had never configured logging at
all.

Two properties of this app shape the image more than anything else:

- **It carries a model runtime.** `torch`, `sentence-transformers` and two
models are in the request path, for the embedder and the cross-encoder.
`torch` alone is 553 MB of the 1.1 GB virtualenv.
- **It must never carry the documents.** `data/sbc/` is 983 MB of issuers'
PDFs, kept forever and strictly local (ADR 0016, ADR 0019). An image that
shipped them would break that, quietly, at a gigabyte a time.

## Decision

### The image runs the API, and nothing else

Ingestion, refresh and the reports stay on the machine that holds `data/`.
Nothing in the image fetches a PDF, and `.dockerignore` excludes `data/`.

**CI proves it rather than trusting it:** the built image is run and
`/app/data` must be empty. A `.dockerignore` regression is otherwise invisible
until an image is gigabytes too large.

### torch comes from PyTorch's CPU index, on Linux only

The default wheels bundle CUDA libraries that a CPU container cannot use.
`pyproject.toml` points `torch` at `https://download.pytorch.org/whl/cpu`
under `sys_platform == 'linux'`, so the image and CI get CPU wheels while
macOS development is untouched. Re-locking removed every `nvidia-*` package
and `triton`.

### Both models are baked in at build time

`SentenceTransformer` and `CrossEncoder` are instantiated during the build, at
the revisions `config.py` pins, into `HF_HOME=/opt/models`. The runtime sets
`HF_HUB_OFFLINE=1`.

- A cold start loads them from the image instead of downloading ~220 MB.
- **A HuggingFace outage cannot take the API down**, which it could if the
first request fetched them.
- The pinned revisions are what ships, so the image is reproducible in the one
way ADR 0008 noted was weak.

### One worker, several threads

Flask-Limiter has no shared storage, so each worker would keep its own
counters and the configured limits would multiply by worker count — the two
that matter being 5/min on register and 10/min on login. Until there is a
shared backend, the image runs **one** gunicorn worker.

Threads carry the concurrency instead, which suits this app: a request waits
on OpenAI and on Postgres, not on CPU. It also avoids paying for the model
runtime N times over in memory.

This is a real ceiling, not a preference, and it is the first thing to revisit
when traffic justifies a Redis backend for the limiter.

### Logs go to stdout in production

`setup_logging()` wrote only to a rotating file. In a container that
filesystem is ephemeral and nobody reads it. In production the root logger
also writes JSON to stdout, where the platform collects it; in development it
does not, so an ingestion run's log lines do not interleave with its own
progress output.

### Every image it depends on is pinned by digest

The base image, the `uv` image the build copies from, and the Trivy image that
scans the result are all pinned `name:tag@sha256:…`. A tag can be moved to
different bytes; a digest cannot. This is the same standard the repository
already applies to GitHub Actions (SHA-pinned) and to gitleaks (installed by
checksum), and the reason is the same: a build that is reproducible only until
someone else re-tags is not reproducible.

### It does not run as root, and does not write to itself

A `policypal` user owns the virtualenv and the source. `LOG_DIR` points at
`/tmp`. The image has no shell-accessible secrets: every setting comes from
the environment, and `.env.example` documents which ones with no values in it.

## Consequences

- **The image is 2.48 GB** — large by ordinary standards, and mostly the model
runtime: CPU torch, and 217 MB of baked weights. Verified in the built
image: `torch 2.14.0+cpu`, **zero** `nvidia-*` packages, no `ruff` or
`pytest`, running as `policypal`, and `/app` holding only `src`,
`migrations` and `alembic.ini`.
- **Proved rather than assumed.** A container with no `data/` directory
(`Path('/app/data').exists()` is `False`) loads the BM25 index from Postgres
and answers "What is a deductible?" with the glossary entry at 0.999,
`HF_HUB_OFFLINE=1` throughout — so both models came from the image and no
request touched HuggingFace. Served over HTTP it returns the right counties
for a ZIP and 401 for an unauthenticated `/api/auth/me`, with access logs on
stdout.
- **Building it is slow on a poor connection.** The model-baking step took
about 7 minutes here, almost all of it downloading from HuggingFace
unauthenticated. CI caches the layer, so it is paid once per lock or config
change, not per build.
- **Cold starts load two models from disk.** Faster than downloading them, but
not free; scale-to-zero trades latency for cost, and the deployment ADR
decides that.
- **A model change is an image rebuild**, which is the point: the revision in
`config.py` and the weights in the image cannot drift apart.
- **The scan runs on every PR** and fails on fixable HIGH or CRITICAL findings.
A base-image CVE now breaks the build, which is the intended cost.
- **CD is still not built.** That is ADR 0002's second deferred item and the
next decision.
Loading
Loading