Spaces:
Running
Running
File size: 6,895 Bytes
bb0af02 f5fc2fb 29a9cba f5fc2fb 29a9cba bb0af02 29a9cba bb0af02 29a9cba bb0af02 29a9cba f5fc2fb b741739 29a9cba bb0af02 f5fc2fb bb0af02 29a9cba bb0af02 29a9cba f5fc2fb 29a9cba f5fc2fb 29a9cba f5fc2fb 29a9cba f5fc2fb 29a9cba f5fc2fb bb0af02 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | # 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"]
|