github-actions[bot]
Deploy 7688ef1
13d4e44
|
Raw
History Blame Contribute Delete
8.94 kB

Deploying the Co-Study4Grid game to a HuggingFace Docker Space

This scaffolds a single-container deployment: one uvicorn process serves the FastAPI backend and the built React frontend (same origin) on port 7860, with the sample grids bundled so Game Mode works out of the box.

It is sized for one player per running Space (the backend holds a single active study — see the caveat at the bottom). For more players, each person clicks Duplicate this Space to get an isolated instance.

What was wired up

File Role
Dockerfile (repo root) Multi-stage build: Vite SPA → Python runtime serving API + SPA + grids on :7860.
.dockerignore (repo root) Trims the build context (no node_modules, .git, Overflow_Graph, …).
deploy/huggingface/README.md The Space README (YAML frontmatter sdk: docker, app_port: 7860) + landing page.
frontend/src/api.ts API base URL is now VITE_API_BASE_URL ?? http://127.0.0.1:8000 → empty in the image = same-origin.
frontend/src/game/gameBridge.ts isGameMode() also honors VITE_GAME_MODE=1, so the Space boots straight into the game.
expert_backend/main.py Serves the built SPA at / when COSTUDY4GRID_FRONTEND_DIST exists; honors $PORT.
.gitattributes (repo root) Tracks *.zip / *.png / *.jpg via Git LFS so binaries can be pushed to the HF Space (its git endpoint rejects non-LFS binaries).
config.default.json (repo root) Seeded into the image; the backend copies it to config.json on first boot (recommender defaults).

The Dockerfile sets VITE_API_BASE_URL="" and VITE_GAME_MODE="1" for the frontend build. Local dev (npm run dev) and the Vitest suite are unaffected (both variables are unset there, so the old :8000 + ?game=1 behavior holds).

Large binaries: Git LFS / Xet

HuggingFace's git endpoint rejects files > 10 MiB and non-LFS binary files. The European grid (pypsa_eur_eur220_225_380_400/network.xiidm, 22 MB) therefore travels as a Git-LFS .zip (2 MB), and the doc images travel as LFS too. .gitattributes (repo root) declares this. The Dockerfile decompresses the .zip back to network.xiidm at build time.

One-time, on your machine:

git lfs install

If your existing binaries were committed before .gitattributes existed, migrate them once so they become LFS objects:

git add --renormalize . && git commit -m "migrate binaries to LFS"

Deploy steps

  1. Create the Space — on huggingface.co: New → Space → Docker → Blank.

  2. Push a single orphan snapshot to the Space. The branch history still contains the > 10 MiB raw .xiidm from older commits, which HF would reject, so push one squashed commit of the current tree (binaries ride along via LFS — no need to delete them anymore):

    git lfs install                                                    # once
    git remote add space https://huggingface.co/spaces/<user>/<space>  # once
    
    git checkout --orphan hf-deploy
    cp deploy/huggingface/README.md README.md   # HF needs the frontmatter at root
    git add -A
    git commit -m "Deploy Co-Study4Grid game"
    git log --oneline hf-deploy                  # MUST be a single commit
    git -c protocol.version=0 push -f space hf-deploy:main
    git checkout -f claude/cool-bell-783agy
    git branch -D hf-deploy
    

    (protocol.version=0 works around a fatal: expected 'acknowledgments' negotiation error some networks hit against HF.) HuggingFace reads the root Dockerfile + the README frontmatter and builds; the first build is long (heavy scientific wheels), later builds reuse layers.

  3. Play — open the Space. It boots straight into the game shell (the VITE_GAME_MODE=1 build flag); the default session is the three European reference studies (Medium difficulty). Build without that flag for the bare workspace.

Automated redeploy on merge to main (GitHub Action)

.github/workflows/deploy-huggingface.yml runs the orphan-snapshot push automatically. It is test-gated (D7): it fires on workflow_run when the Tests workflow completes successfully on main (not merely on a merge), checks out that exact tested commit with LFS, squashes it to one history-free commit, and force-pushes it to the Space's main. A manual workflow_dispatch always runs (the rollback path — see below).

Opt in by setting, in the GitHub repo Settings → Secrets and variables → Actions:

Kind Name Value
Secret HF_TOKEN a HuggingFace write access token with access to the Space
Variable HF_SPACE the Space path, e.g. your-user/co-study4grid-game
Variable HF_USERNAME (optional) the token owner's HF username — only when the Space is under an org and so differs from the owner part of HF_SPACE

The job is inert (it logs a notice and exits cleanly) until both HF_TOKEN and HF_SPACE are set, so merging the workflow doesn't break anything before you opt in. The push reuses LFS objects already on the Space, so repeat deploys only upload what changed.

Rolling back a bad deploy

The Space push is force-pushed and history-free, so the Space's own git log is not a rollback trail. Instead, every successful deploy tags the exact commit it shipped on origin as space-deploy-<UTC-timestamp>-<shortsha>. To roll back:

  1. Find the last good tag: git tag --list 'space-deploy-*' | sort | tail.
  2. Re-deploy that commit: Actions → Deploy to HuggingFace Space → Run workflow, and pick the good tag (or its commit) as the ref. The manual workflow_dispatch path is not test-gated, so it redeploys immediately.

Reproducible Python closure (tracked follow-up)

The image still resolves the Python dependency tree at build time (floors in pyproject.toml + the recommender floor), so a rebuild can pick up newer transitive releases. To make the build reproducible, generate a lockfile on Python 3.10 (matching the image base) and have both the Dockerfile and the Tests workflow install from it:

# on Python 3.10:
pip install pip-tools
pip-compile --output-file requirements.lock pyproject.toml

Commit requirements.lock and pip install -r requirements.lock in both places so "the Docker image mirrors CI" becomes literally true. (Not generated in-repo yet: a lockfile must be resolved on the deployment's 3.10 interpreter, not a dev 3.11, or it pins the wrong wheels.)

Lockdown profile (hosted deployments)

The Docker image sets COSTUDY4GRID_LOCKDOWN=1 (see the Dockerfile). On the public Space the desktop-era filesystem RPCs — custom config-file path, session save / list / load, and the native file picker — are disabled with a 403 {code: "LOCKED_DOWN"}, because they assume "the client is the operator on their own machine" and would otherwise give an anonymous visitor read/write access to the container filesystem. The read-only app config (GET /api/user-config, GET /api/config-file-path) stays available so the SPA boots. Local/dev installs leave the variable unset and behave exactly as before. See docs/architecture/deployment-trust.md.

Test the image locally first (recommended)

docker build -t costudy4grid-game .
docker run --rm -p 7860:7860 costudy4grid-game
# → open http://localhost:7860

The build has been validated end to end: the frontend bundle, the full scientific stack (pypowsybl, ExpertOp4Grid, grid2op, pandapower, lightsim2grid), backend import (42 routes), uvicorn on :7860, same-origin SPA + /api/models, and the bundled fr225_400 grid loading via pypowsybl all work. Image is ~370 MB.

Notes if you reproduce the build:

  • The recommender is installed with --no-deps on purpose — its own dependency tree is self-conflicting (numpy>=2 vs its transitive pypowsybl2grid's numpy==1.26.4). The working runtime deps come from pip install . + overrides.txt. This mirrors CI.
  • If docker pull of the base images hits Docker Hub's anonymous rate limit, either docker login or point the daemon at a pull-through mirror (/etc/docker/daemon.json: {"registry-mirrors": ["https://mirror.gcr.io"]}) and restart it.
  • If a dependency lacks a prebuilt wheel on the build host, add build-essential to the apt-get install line in the runtime stage and rebuild.

Caveat: one active study per instance

network_service and recommender_service are module-level singletons holding one study's state, so a single Space is effectively single-tenant. Two players hitting the same Space concurrently will clobber each other's network load. For a public competition, prefer Duplicate this Space per player, or refactor the backend to be session-scoped before a shared multi-player launch.