Spaces:
Running
Running
| # 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"] | |