teaching-bot / Dockerfile
ci
Deploy main (689bcbb97f313eefb4c09e894ef4c68ff9e73ae1)
29a9cba
Raw
History Blame Contribute Delete
6.9 kB
# syntax=docker/dockerfile:1
#
# THE backend image β€” one Dockerfile for all three consumers, so the build surface
# (deps, Chromium, runtime) can never drift between them:
# β€’ Production / Hugging Face Space β€” built with the DEFAULT args (SOURCE_MODE=clone).
# This is the CANONICAL production target, mirrored into the public HF Space repo
# (huggingface.co/spaces/lvhoang/teaching-bot), whose only tracked files are this
# Dockerfile plus DEPLOY_REF / DEPLOY_SHA β€” never application source.
# β€’ CI build/import smoke and local docker-compose β€” built with SOURCE_MODE=local,
# which COPYs source from the build context instead of cloning. The clone stage is
# then outside the build graph, so no GH_TOKEN / network / pushed ref is needed.
# See README.md in this directory for the deploy model and the one-time Space sync.
#
# Leak invariant: source lives only in the PRIVATE GitHub repo. The production build
# clones it at DEPLOY_REF using a build-time secret (GH_TOKEN) in the `src-clone`
# stage; only the built venv + the shipped packages are copied into the final image.
# Nothing is committed to the public Space, and the token + .git never exist in a
# final-image layer. The *endpoint* is public (the frontend calls it); the *source* is
# not. GH_TOKEN MUST be a fine-grained, read-only, single-repo, short-lived PAT so a
# leaked build log has minimal blast radius. The clone reads $(cat /run/secrets/...)
# so BuildKit never prints the expanded token. The `src-local` stage's COPYs are inert
# in the Space build (that stage is never built when SOURCE_MODE=clone), so they can
# never sweep local files into the public-endpoint image.
# DEFAULT = clone, so the HF Space build (which passes no build args) produces prod.
ARG SOURCE_MODE=clone
##############################################################################
# Source acquisition β€” two interchangeable stages, selected by SOURCE_MODE. #
# Each assembles the shipped layout under /app: the explicit package list is #
# deliberate (a wholesale `backend/` copy could sweep a stray .env / .git in).#
# backend/tests/test_hf_space_build.py guards that this list stays in sync #
# with the wheel packages in pyproject.toml. start.sh comes from the SOURCE #
# (infra/hf-space/start.sh), never the Space repo, so it can't drift from the #
# app it launches. #
##############################################################################
FROM python:3.12-slim AS src-clone
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
COPY DEPLOY_REF /tmp/DEPLOY_REF
RUN --mount=type=secret,id=GH_TOKEN \
REF="$(tr -d '\r\n' < /tmp/DEPLOY_REF)" \
&& git clone --depth 1 --branch "$REF" \
"https://x-access-token:$(cat /run/secrets/GH_TOKEN)@github.com/henryle97/teaching-bot" /tmp/src \
&& mkdir -p /app \
&& cp -r /tmp/src/backend/app /tmp/src/backend/engine /tmp/src/backend/worker \
/tmp/src/backend/pyproject.toml /tmp/src/backend/uv.lock /app/ \
&& cp /tmp/src/infra/hf-space/start.sh /app/start.sh \
&& rm -rf /tmp/src
FROM python:3.12-slim AS src-local
COPY backend/app /app/app
COPY backend/engine /app/engine
COPY backend/worker /app/worker
COPY backend/pyproject.toml backend/uv.lock /app/
COPY infra/hf-space/start.sh /app/start.sh
# Pick the source stage. `src-${SOURCE_MODE}` resolves to src-clone or src-local;
# only the selected one is in the build graph (so local builds skip the clone/secret).
FROM src-${SOURCE_MODE} AS source
##############################################################################
# Builder β€” build the venv. Discarded afterward, so its uv/git/curl never #
# reach the published image. No build-essential: every dependency resolves to #
# a prebuilt manylinux wheel, so `uv sync --frozen` needs no C toolchain. #
##############################################################################
FROM python:3.12-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH=/root/.local/bin:$PATH
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
WORKDIR /app
COPY --from=source /app /app
# --no-dev: the image never runs pytest/ruff/respx, so the dev group is excluded.
RUN uv sync --frozen --no-install-project --no-dev
##############################################################################
# Runtime β€” slim base + Chromium + the prebuilt venv. No compilers, no uv, #
# no git, no clone temp; runs as the unprivileged `user`. #
##############################################################################
FROM python:3.12-slim
RUN useradd -m -u 1000 user
ENV HOME=/home/user \
PATH=/app/.venv/bin:$PATH \
PYTHONUNBUFFERED=1 \
# Shared, world-readable Chromium location so the unprivileged runtime user finds
# the browser installed (as root) at build time. Used by engine/thumbnail.py.
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# Headless Chromium + its system libraries for first-slide thumbnails
# (engine/thumbnail.py, worker-only). Deliberately the FIRST runtime layer, above the
# COPY of the app: the browser binary and apt libs never change between deploys, so
# keying this layer only on the pinned PLAYWRIGHT_VERSION lets every ordinary deploy
# reuse it from cache instead of re-downloading Chromium each build. We pip-install the
# playwright CLI just to fetch the browser into the shared PLAYWRIGHT_BROWSERS_PATH +
# the system libs, then drop the CLI; the copied venv ships the matching playwright,
# which finds these prebaked binaries at runtime. `--only-shell` installs only
# chrome-headless-shell (~175MB smaller than the full browser) β€” we only ever render
# headless (engine/thumbnail.py pins channel="chromium-headless-shell").
# PLAYWRIGHT_VERSION must equal the playwright version in backend/uv.lock β€” guarded by
# backend/tests/test_hf_space_build.py. Best-effort by design: if a launch ever fails
# at runtime, render_thumbnail returns None and the dashboard falls back to ThemeArt β€”
# and the thumbnails_enabled env flag can disable rendering entirely without a rebuild.
ARG PLAYWRIGHT_VERSION=1.60.0
RUN pip install --no-cache-dir "playwright==${PLAYWRIGHT_VERSION}" \
&& apt-get update \
&& playwright install-deps chromium \
&& playwright install --only-shell chromium \
&& chmod -R a+rX "$PLAYWRIGHT_BROWSERS_PATH" \
&& pip uninstall -y playwright \
&& rm -rf /var/lib/apt/lists/*
# The venv is built at the same /app/.venv path it runs from, so its interpreter
# shebangs and pyvenv.cfg stay valid across the stage boundary.
COPY --from=builder --chown=user:user /app /app
WORKDIR /app
USER user
EXPOSE 7860
CMD ["bash", "start.sh"]