diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile index fee61bb..6881aed 100644 --- a/.github/docker/Dockerfile +++ b/.github/docker/Dockerfile @@ -6,21 +6,27 @@ # Two separate Python environments by design: # - the kernel env is the system Python (/usr/local): pins go in with # `uv pip install --system`, matching both the Colab bootstrap cell and -# run_notebook.py, so the CI harness runs unmodified inside the image; +# run_notebook.py, so the CI harness runs unmodified inside the image +# (CI runs it with --user root so its transient installs can write there); # - JupyterLab lives in an isolated `uv tool` env so its own dependency # tree can never upgrade or downgrade anything in the pinned kernel env. +# +# The server runs as the non-root user jovyan (uid 1000). ARG BASE_IMAGE=python:3.12-slim@sha256:2c941e860699f878900b0edc2403613c234d4b32eda3cc9fa7036991a2a63c4a FROM ${BASE_IMAGE} RUN apt-get update \ && apt-get install -y --no-install-recommends curl git ca-certificates \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 1000 --shell /bin/bash jovyan COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /uvx /usr/local/bin/ +# The tool env lives under /opt (not /root) so it is readable by jovyan. ARG JUPYTERLAB_VERSION=4.6.3 -ENV UV_TOOL_BIN_DIR=/opt/uv-bin +ENV UV_TOOL_DIR=/opt/uv-tools \ + UV_TOOL_BIN_DIR=/opt/uv-bin RUN uv tool install "jupyterlab==${JUPYTERLAB_VERSION}" # JUPYTER_PATH makes the pinned kernelspec under /usr/local win over the # kernelspec that ships inside the JupyterLab tool env. @@ -29,12 +35,33 @@ ENV PATH="/opt/uv-bin:${PATH}" \ # ipykernel and nbformat resolve jointly with the pins in one invocation so # any conflict fails the build instead of silently changing a pinned version. +# gcc/libc6-dev cover pins with no wheel for the target arch (e.g. old psutil +# on arm64) and are purged in the same layer to keep the image slim. COPY requirements.txt /tmp/requirements.txt -RUN uv pip install --system --no-cache -r /tmp/requirements.txt ipykernel nbformat \ - && python -m ipykernel install --name python3 --display-name "Python 3 (pinned)" +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libc6-dev \ + && uv pip install --system --no-cache -r /tmp/requirements.txt ipykernel nbformat \ + && python -m ipykernel install --name python3 --display-name "Python 3 (pinned)" \ + && apt-get purge -y gcc libc6-dev \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* WORKDIR /work -COPY work/ /work/ +COPY --chown=jovyan:jovyan work/ /work/ + +USER jovyan +ENV HOME=/home/jovyan + +# Matplotlib builds its font cache on first import; do it at build time (as +# jovyan, so the cache lands in the home directory the server runs from) so +# the first cell a user runs doesn't stall on it. Under Rosetta emulation the +# subprocess spawn involved can even deadlock, so it must not happen at runtime. +RUN python - <<'PYEOF' +try: + import matplotlib.pyplot # noqa: F401 +except ImportError: + pass +PYEOF ARG DEFAULT_NOTEBOOK ARG BUILD_HASH @@ -47,5 +74,5 @@ LABEL org.opencontainers.image.source="https://github.com/dandi/example-notebook org.dandiarchive.build-hash="${BUILD_HASH}" EXPOSE 8888 -CMD jupyter-lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root \ +CMD jupyter-lab --ip=0.0.0.0 --port=8888 --no-browser \ --ServerApp.default_url="/lab/tree/${DEFAULT_NOTEBOOK}" diff --git a/.github/docker/README.md b/.github/docker/README.md index 5544cbc..1489057 100644 --- a/.github/docker/README.md +++ b/.github/docker/README.md @@ -11,18 +11,27 @@ remains runnable long after the hosted environments have moved on. ## Running a Notebook Image ``` -docker run --rm -p 8888:8888 ghcr.io/dandi/example-notebooks/001550-paganlab:latest +docker run --rm -p 127.0.0.1:8888:8888 ghcr.io/dandi/example-notebooks/001550-paganlab:latest ``` Then open the `http://127.0.0.1:8888/lab?token=...` URL printed in the -terminal. JupyterLab opens on the notebook with its dependencies already +terminal. If you already have a Jupyter server running on port 8888 (common), +Docker will refuse to start with an "address already in use" error; pick +another host port, e.g. `-p 127.0.0.1:8890:8888`, and open +`http://127.0.0.1:8890/lab?token=...` instead. The explicit `127.0.0.1:` in +the port mapping matters: it keeps the server off your network interfaces, +and it makes a port collision fail loudly instead of silently routing your +browser to the other Jupyter server. JupyterLab opens on the notebook with its dependencies already installed; the install cell at the top is a no-op and can be skipped. The notebooks stream data from the DANDI Archive, so network access is still required at run time. -Images are built for `linux/amd64`, the platform the dependency pins were -resolved for. On Apple Silicon, Docker Desktop runs them under emulation; -pass `--platform linux/amd64` to silence the platform warning. +Images are multi-arch (`linux/amd64` and `linux/arm64`), so Apple Silicon +machines run them natively rather than under emulation. Emulated execution is +not just slow: Rosetta can deadlock on subprocess spawns inside the kernel, +which shows up as cells hanging forever. If you previously pulled an +amd64-only version of an image, run `docker pull` again to pick up the +multi-arch manifest. Images are named after the notebook directory (lowercased, with `/` replaced by `-`). When notebooks in the same directory pin different dependency sets, @@ -55,7 +64,14 @@ cell and the CI harness. JupyterLab runs from an isolated `uv tool` environment so its own dependencies cannot perturb the pinned kernel environment. `ipykernel` and `nbformat` are the only additions to the pinned set; they resolve jointly with the pins so a conflict fails the build rather -than silently changing a pinned version. +than silently changing a pinned version. The matplotlib font cache is built +at image-build time so the first import in a fresh container doesn't stall +on it. + +Each architecture is built and verified on its own native runner +(`ubuntu-latest` for amd64, `ubuntu-24.04-arm` for arm64) — no emulation +anywhere in the pipeline — then the per-arch images are merged into one +multi-arch manifest carrying the user-facing tags. ## Maintainer Notes @@ -72,7 +88,13 @@ than silently changing a pinned version. bump it, update the digest, which changes every group's build hash, and dispatch a full rebuild. The `BASE_IMAGE` arg is also the knob for a future variant based on the official Colab runtime image. -- Images run as root (`python:3.12-slim` has no unprivileged user) and keep - Jupyter's token auth enabled. The published port binding in the docs is - loopback-only via `-p 8888:8888` on a local machine; advise users not to - bind on public interfaces. +- The server runs as the non-root user `jovyan` (uid 1000) with Jupyter's + token auth enabled. CI's verification step runs the container with + `--user root` so the harness's transient installs can write to the system + Python; the published default stays non-root. The documented port mapping + binds the host side to `127.0.0.1` explicitly; a bare `-p 8888:8888` would + bind all interfaces, and on macOS it also loses silently to any local + Jupyter server already listening on `127.0.0.1:8888`. +- The `hash-<12 hex>-amd64` / `-arm64` tags are the per-arch build artifacts + the merge step assembles into the multi-arch `hash-<12 hex>` manifest; they + also serve as the per-arch skip markers. diff --git a/.github/workflows/build-notebook-images.yml b/.github/workflows/build-notebook-images.yml index 08ef2e9..8a5def2 100644 --- a/.github/workflows/build-notebook-images.yml +++ b/.github/workflows/build-notebook-images.yml @@ -1,9 +1,10 @@ name: Build notebook images -# Builds a container image per notebook group (directory + identical pin set), -# verifies every notebook in the group runs inside the candidate image using -# the same harness as the test workflows, and pushes verified images to -# ghcr.io. Only verified images are ever published. +# Builds a container image per notebook group (directory + identical pin set) +# for both amd64 and arm64 on native runners, verifies every notebook in the +# group inside the candidate image using the same harness as the test +# workflows, pushes per-arch images, and merges them into one multi-arch +# manifest on ghcr.io. Only verified images are ever published. on: workflow_dispatch: @@ -51,13 +52,16 @@ jobs: build: needs: list if: needs.list.outputs.groups != '[]' - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner.os }} timeout-minutes: 90 strategy: fail-fast: false max-parallel: 6 matrix: group: ${{ fromJSON(needs.list.outputs.groups) }} + runner: + - { os: ubuntu-latest, arch: amd64 } + - { os: ubuntu-24.04-arm, arch: arm64 } steps: - uses: actions/checkout@v4 @@ -83,11 +87,11 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Skip if an image with this build hash is already published + - name: Skip if this build hash is already published for this arch id: check run: | IMAGE='${{ steps.prepare.outputs.image }}' - TAG='hash-${{ steps.prepare.outputs.build_hash_short }}' + TAG='hash-${{ steps.prepare.outputs.build_hash_short }}-${{ matrix.runner.arch }}' if [ '${{ inputs.force }}' != 'true' ] \ && docker manifest inspect "$IMAGE:$TAG" >/dev/null 2>&1; then echo "Image $IMAGE:$TAG already exists; skipping build and push." @@ -100,7 +104,7 @@ jobs: if: steps.check.outputs.up_to_date == 'false' run: | docker build \ - --platform linux/amd64 \ + --platform 'linux/${{ matrix.runner.arch }}' \ -f .github/docker/Dockerfile \ --build-arg DEFAULT_NOTEBOOK='${{ steps.prepare.outputs.default_notebook }}' \ --build-arg BUILD_HASH='${{ steps.prepare.outputs.build_hash }}' \ @@ -111,13 +115,15 @@ jobs: - name: Verify notebooks inside the candidate image if: steps.check.outputs.up_to_date == 'false' + # --user root so the harness's transient uv installs can write to + # /usr/local; the pushed image still defaults to the jovyan user. run: | mkdir -p "$RUNNER_TEMP/verify" echo '${{ steps.prepare.outputs.notebooks }}' \ | python3 -c 'import json, sys; print("\n".join(json.load(sys.stdin)))' \ | while IFS= read -r nb; do echo "::group::verify $nb" - docker run --rm \ + docker run --rm --user root \ -v "$PWD/.github/scripts/run_notebook.py:/ci/run_notebook.py:ro" \ -v "$RUNNER_TEMP/verify:/out" \ '${{ steps.prepare.outputs.image }}:candidate' \ @@ -129,23 +135,68 @@ jobs: if: always() && steps.check.outputs.up_to_date == 'false' uses: actions/upload-artifact@v4 with: - name: verify-${{ matrix.group }} + name: verify-${{ matrix.group }}-${{ matrix.runner.arch }} path: ${{ runner.temp }}/verify/ retention-days: 30 if-no-files-found: ignore - - name: Push verified image + - name: Push verified per-arch image if: steps.check.outputs.up_to_date == 'false' && inputs.push run: | IMAGE='${{ steps.prepare.outputs.image }}' - for tag in latest \ - "sha-$(echo '${{ github.sha }}' | cut -c1-7)" \ - "$(date -u +%Y-%m-%d)" \ - 'hash-${{ steps.prepare.outputs.build_hash_short }}'; do - docker tag "$IMAGE:candidate" "$IMAGE:$tag" - docker push "$IMAGE:$tag" - done + TAG='hash-${{ steps.prepare.outputs.build_hash_short }}-${{ matrix.runner.arch }}' + docker tag "$IMAGE:candidate" "$IMAGE:$TAG" + docker push "$IMAGE:$TAG" - name: Disk usage telemetry if: always() run: df -h + + merge: + needs: [list, build] + if: needs.list.outputs.groups != '[]' && inputs.push + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + group: ${{ fromJSON(needs.list.outputs.groups) }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install runner dependencies + run: pip install --no-cache-dir nbformat + + - name: Recompute image name and build hash + id: prepare + run: | + python .github/scripts/build_notebook_image.py prepare \ + --name '${{ matrix.group }}' \ + --context-dir "$RUNNER_TEMP/context" \ + --image-prefix "ghcr.io/${{ github.repository }}" + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Merge per-arch images into a multi-arch manifest + run: | + IMAGE='${{ steps.prepare.outputs.image }}' + H='hash-${{ steps.prepare.outputs.build_hash_short }}' + if [ '${{ inputs.force }}' != 'true' ] \ + && docker manifest inspect "$IMAGE:$H" >/dev/null 2>&1; then + echo "Manifest $IMAGE:$H already exists; skipping merge." + exit 0 + fi + docker buildx imagetools create \ + -t "$IMAGE:latest" \ + -t "$IMAGE:sha-$(echo '${{ github.sha }}' | cut -c1-7)" \ + -t "$IMAGE:$(date -u +%Y-%m-%d)" \ + -t "$IMAGE:$H" \ + "$IMAGE:$H-amd64" "$IMAGE:$H-arm64"