diff --git a/Dockerfile b/Dockerfile index 610c80f..537c5f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:latest +FROM python:3.14 RUN apt-get update && apt-get install -y \ git \ @@ -30,13 +30,16 @@ RUN --mount=type=cache,target=/root/.cache/uv uv sync --no-dev --frozen # multi-hundred-MB downloads on first use. Stored in ~/.brainglobe/ inside # the image layer; mount a Docker volume there in production to persist any # additional atlases users request across container restarts. -# check_latest=False avoids hanging the build if the GIN server is down. +# +# The GIN server that hosts the atlases is frequently slow or unresponsive. We +# require the atlases to be baked in: `timeout` bounds the download so a stalled +# GIN server fails the build instead of hanging it forever, and any failure +# propagates (the build fails) rather than shipping an image without atlases. +# Bump ATLAS_FETCH_TIMEOUT if downloads legitimately need longer. ENV PATH="/app/.venv/bin:$PATH" -RUN python -c "\ -from brainglobe_atlasapi import BrainGlobeAtlas; \ -BrainGlobeAtlas('allen_mouse_25um', check_latest=False); \ -BrainGlobeAtlas('whs_sd_rat_39um', check_latest=False); \ -" +ENV ATLAS_FETCH_TIMEOUT=1200 +COPY fetch_atlases.py /fetch_atlases.py +RUN timeout "${ATLAS_FETCH_TIMEOUT}" python /fetch_atlases.py # Expose the port ENV INTERNAL_PORT=5008 diff --git a/entrypoint.sh b/entrypoint.sh index 360ef83..da9e8ec 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,9 +11,25 @@ echo "$(date '+%Y-%m-%d %H:%M:%S') ADDRESS $ADDRESS" echo "$(date '+%Y-%m-%d %H:%M:%S') ALLOW_WEBSOCKET_ORIGIN $ALLOW_WEBSOCKET_ORIGIN" echo "$(date '+%Y-%m-%d %H:%M:%S') NUM_PROCS $NUM_PROCS" +# Verify the required atlases are present before serving. They are normally +# baked into the image at build time, so this is a fast no-op; it only does work +# if a runtime volume mount shadowed ~/.brainglobe/. REQUIRE_ATLASES=1 +# (default) makes a still-missing atlas a hard startup failure; set +# REQUIRE_ATLASES=0 to warn and start anyway (atlases download lazily on use). +REQUIRE_ATLASES=${REQUIRE_ATLASES:-1} +echo "$(date '+%Y-%m-%d %H:%M:%S') verifying atlases ..." +if ! uv run --no-dev python /fetch_atlases.py; then + if [ "$REQUIRE_ATLASES" = "1" ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: required atlases unavailable; aborting startup" >&2 + exit 1 + fi + echo "$(date '+%Y-%m-%d %H:%M:%S') WARNING: atlases unavailable; continuing (will download lazily)" >&2 +fi -# Start the Panel application -exec uv run panel serve ./app.py \ +# Start the Panel application. --no-dev matches the build-time `uv sync +# --no-dev` so `uv run` doesn't re-sync dev dependencies (sphinx, etc.) into the +# environment at container startup. +exec uv run --no-dev panel serve ./app.py \ --address "$ADDRESS" \ --port "$INTERNAL_PORT" \ --allow-websocket-origin "$ALLOW_WEBSOCKET_ORIGIN" \ diff --git a/fetch_atlases.py b/fetch_atlases.py new file mode 100644 index 0000000..3e591c4 --- /dev/null +++ b/fetch_atlases.py @@ -0,0 +1,61 @@ +"""Ensure the atlases PixelMap needs are present in the local brainglobe cache. + +Used in two places: + +* At Docker build time (under `timeout`), to bake the atlases into the image + layer. The build *requires* this to succeed: a non-zero exit fails the build + so we never ship an image without atlases, and `timeout` turns a stalled GIN + server into a bounded failure instead of an infinite hang. +* At container startup (via entrypoint.sh), as a cheap safety net: it re-verifies + the atlases are present (e.g. in case a runtime volume mount shadowed + ~/.brainglobe/) and re-fetches anything missing before the app boots. + +Because both callers run the same code, the startup check is a no-op in the +normal case — brainglobe stores atlases on disk and we skip any that are already +downloaded. + +Exit codes: + 0 all required atlases are present (downloaded now or already cached) + 1 at least one required atlas is still missing after attempting a download +""" + +from __future__ import annotations + +import sys + +from brainglobe_atlasapi import BrainGlobeAtlas +from brainglobe_atlasapi.list_atlases import get_downloaded_atlases + +# Atlases baked into the image / required for the app to be useful offline. +REQUIRED_ATLASES = ( + "allen_mouse_25um", + "whs_sd_rat_39um", +) + + +def ensure_atlas(name: str) -> bool: + """Make sure `name` is downloaded. Return True if present afterwards.""" + if name in get_downloaded_atlases(): + print(f"atlas already cached: {name}") + return True + try: + # Constructing the atlas triggers the download into ~/.brainglobe/. + # check_latest=False avoids the remote version check hanging the boot. + print(f"downloading atlas: {name} ...") + BrainGlobeAtlas(name, check_latest=False) + except Exception as exc: # network/GIN failure — report, don't crash caller + print(f"failed to download {name}: {exc}", file=sys.stderr) + return name in get_downloaded_atlases() + + +def main() -> int: + missing = [name for name in REQUIRED_ATLASES if not ensure_atlas(name)] + if missing: + print(f"missing atlases after fetch attempt: {', '.join(missing)}", + file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())