# syntax=docker/dockerfile:1 # ───────────────────────────────────────────────────────────────────────────── # loosecanvas — single-container HF Docker Space (Build Small Hackathon). # # One image, two processes (entrypoint.sh): native llama.cpp `llama-server` on # 127.0.0.1:8080 (SAME binary + flags validated locally — the structured-output # path is build-sensitive, so we do NOT swap to llama-cpp-python) and uvicorn on # the ONE public port (HF proxies HTTPS to app_port). # # Multi-stage: the `builder` has Node + build tools to compile the Svelte custom # component; the `runtime` stage drops them and copies only the ready /opt/venv + # app source + the native llama-server (already in the base). GPU is runtime-only # on HF — nothing here runs a CUDA command at build. # # Model handling (docker best practice — keep the image small, don't re-download): # • BAKE_MODEL=0 (DEFAULT): model NOT in the image. The entrypoint downloads it # once to $MODEL_DIR. Mount a persistent HF Storage Bucket at $MODEL_DIR so it # survives restarts/rebuilds and is fetched exactly once. Locally: bind-mount # ./models and it is never downloaded at all. # • BAKE_MODEL=1: bake the GGUF + MTP draft into the image (self-contained, zero # cold-start download — simplest/most reliable for a judged Space, ~+14 GB). # # Build (fast, no model layer): docker build -t loosecanvas . # Self-contained (bakes ~14 GB): docker build --build-arg BAKE_MODEL=1 -t loosecanvas . # ───────────────────────────────────────────────────────────────────────────── # Pinned by digest (resolved 2026-06-14) — a moving tag can silently change the # llama-server CLI / structured-output behaviour. Re-pin deliberately on bump. ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:full-cuda@sha256:e9ecf4e6e88b0c0677d1e9edd4ac27c942fb0bac5fedaf821f391e4b244efba9 # Fixed locations so the venv is self-contained + copyable across stages. ARG VENV=/opt/venv ARG UVPY=/opt/uv-python # ============================================================================ # Stage 1 — builder: compile the custom component + resolve the venv. # ============================================================================ FROM ${LLAMACPP_IMAGE} AS builder ARG VENV ARG UVPY ENV DEBIAN_FRONTEND=noninteractive \ UV_LINK_MODE=copy \ UV_PYTHON_INSTALL_DIR=${UVPY} \ UV_PROJECT_ENVIRONMENT=${VENV} \ VIRTUAL_ENV=${VENV} \ PATH=/root/.local/bin:${VENV}/bin:${PATH} # build deps: node (custom-component frontend) + git/curl. apt + uv caches mounted. RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates git \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && node --version && npm --version # uv + Python 3.13. RUN curl -LsSf https://astral.sh/uv/install.sh | sh RUN --mount=type=cache,target=/root/.cache/uv \ uv python install 3.13 # Build at the SAME path the runtime uses so the editable install's .pth and the # app's fixture lookup (parents[2] of main.py) resolve after the cross-stage copy. WORKDIR /home/user/app # Deps layer keyed on the lockfile only (cached across source edits). COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev --no-install-project # Full source, then install the project + build the Svelte custom component. COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev \ && uv pip install build \ && uv pip install -e ./cytoscapecanvas # Frontend deps (package-lock.json is committed → reproducible npm ci). RUN --mount=type=cache,target=/root/.npm \ cd cytoscapecanvas/frontend && npm ci --cache /root/.npm # Compile Svelte → templates/, build the wheel, then install the real wheel # (replaces the editable install with one that bundles the built templates). # Invoke gradio via the venv binary (NOT `uv run` from the subdir: uv would treat # the component pyproject as a standalone project and fail resolution). RUN cd cytoscapecanvas \ && ${VENV}/bin/gradio cc build --no-generate-docs --python-path ${VENV}/bin/python \ && uv pip install --no-deps --force-reinstall dist/*.whl # ============================================================================ # Stage 2 — runtime: native llama-server (base) + the built venv + app source. # ============================================================================ FROM ${LLAMACPP_IMAGE} AS runtime ARG VENV ARG UVPY # curl is needed by the entrypoint health probe. RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends curl ca-certificates # HF Spaces run the container as uid 1000; the base (Ubuntu noble) already has a # uid-1000 user — remove whoever owns it, then create our own. RUN (userdel -r "$(getent passwd 1000 | cut -d: -f1)" 2>/dev/null || true) \ && useradd -m -u 1000 -s /bin/bash user ENV HOME=/home/user \ PATH=${VENV}/bin:/app:/home/user/.local/bin:${PATH} \ HF_HOME=/home/user/.cache/huggingface \ PYTHONUNBUFFERED=1 \ LD_LIBRARY_PATH=/app:/usr/local/cuda/lib64 # The native llama.cpp shared libs (libllama.so, libggml*.so, …) live in /app. # The base image's /app/tools.sh entrypoint sets this up; we invoke llama-server # directly, so we add /app to the loader path ourselves. # The compiled venv + its managed Python (identical paths → venv symlinks resolve). COPY --from=builder --chown=user ${VENV} ${VENV} COPY --from=builder --chown=user ${UVPY} ${UVPY} # App source at the SAME path used in the builder so the editable .pth + fixtures/ resolve. COPY --from=builder --chown=user /home/user/app /home/user/app # chown the app dir itself (COPY's --chown sets contents, but the top dir can stay # root-owned → uid-1000 mkdir later fails), then drop build-only weight. RUN chown user:user /home/user/app \ && rm -rf /home/user/app/cytoscapecanvas/frontend/node_modules \ /home/user/app/cytoscapecanvas/dist /home/user/app/.git WORKDIR /home/user/app # ── model source (public/ungated Unsloth repo — no HF token) ──────────────── ARG BAKE_MODEL=0 ARG MODEL_REPO=unsloth/gemma-4-26B-A4B-it-qat-GGUF ARG MODEL_FILE=gemma-4-26B-A4B-it-qat-UD-Q4_K_XL.gguf ARG MTP_REPO=unsloth/gemma-4-26B-A4B-it-qat-GGUF ARG MTP_FILE=mtp-gemma-4-26B-A4B-it.gguf ENV MODEL_REPO=${MODEL_REPO} \ GEMMA_MODEL_FILENAME=${MODEL_FILE} \ MTP_REPO=${MTP_REPO} \ MTP_MODEL_FILE=${MTP_FILE} \ MODEL_DIR=/home/user/app/models USER user RUN mkdir -p "$MODEL_DIR" \ && if [ "$BAKE_MODEL" = "1" ]; then \ echo "Baking model + MTP draft from ${MODEL_REPO}…" && \ python -c "import os;from huggingface_hub import hf_hub_download as d;d(os.environ['MODEL_REPO'],os.environ['GEMMA_MODEL_FILENAME'],local_dir=os.environ['MODEL_DIR']);d(os.environ['MTP_REPO'],os.environ['MTP_MODEL_FILE'],local_dir=os.environ['MODEL_DIR'])" ; \ else \ echo "BAKE_MODEL=0 — models NOT baked; entrypoint downloads to \$MODEL_DIR (mount a persistent volume there to fetch once)." ; \ fi # ── runtime config (the app reads these via raw os.environ) ───────────────── # ctx 32768 fits the 24 GB L4 with MTP on with headroom (base ~13.5 GB + MTP # ~2 GB); 65536 is also viable on the L4. Set LLAMA_CPP_MTP=0 on a 16 GB GPU. ENV LLAMA_CPP_PORT=8080 \ LLAMA_CPP_CTX=32768 \ LLAMA_CPP_MTP=1 \ SPEC_DRAFT_N_MAX=2 \ APP_PORT=7860 \ LOOSECANVAS_ALLOWED_BASE=/home/user/app \ LOOSECANVAS_DEMO_SEED=1 # DEMO_SEED=1 seeds a graph on first paint for the public demo (instant magic); # the default app (env unset) keeps the P0-01 blank-slate workspace. EXPOSE 7860 COPY --chown=user entrypoint.sh /home/user/entrypoint.sh RUN chmod +x /home/user/entrypoint.sh ENTRYPOINT ["/home/user/entrypoint.sh"]