From 560ec860dfb55212eab3ebb8a8daa6dd07085267 Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:27:17 -0700 Subject: [PATCH] fix(docker): build native Rust extension into images (#590) Build and install the mandatory openjarvis_rust wheel in the CPU, NVIDIA, ROCm, and sandbox Docker images. Rust 1.88 (matching the workspace MSRV / rust-toolchain.toml) and maturin are installed only in the builder stage, the module's import is verified during the build, and maturin is removed before the runtime artifacts are copied so build tooling never ships. The frontend leaderboard anon key is an optional empty-by-default build arg (post-#589), so default images cleanly disable the leaderboard. Adds static deployment coverage for the native build path. Closes #584. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/docker/Dockerfile | 30 ++++++++++++++++-- deploy/docker/Dockerfile.gpu | 35 ++++++++++++++++++--- deploy/docker/Dockerfile.gpu.rocm | 35 ++++++++++++++++++--- deploy/docker/Dockerfile.sandbox | 51 +++++++++++++++++++++++-------- tests/deployment/test_docker.py | 29 ++++++++++++++++++ 5 files changed, 157 insertions(+), 23 deletions(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 9ff033ff..177f5ac7 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -4,16 +4,30 @@ # Stage 1: Build frontend SPA FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend +# Public Supabase anon key for the savings leaderboard; empty by default so +# the image's leaderboard stays disabled (#589). Pass --build-arg to enable. +ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON= WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ RUN npm ci --ignore-scripts 2>/dev/null || npm install COPY frontend/ . -RUN npm run build +RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build # Stage 2: Build Python package FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* + +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain none && \ + rustup toolchain install 1.88 --profile minimal && \ + rustup default 1.88 + WORKDIR /app # Install dependencies from the committed lockfile (#567). `uv export --frozen` @@ -24,11 +38,13 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md ./ RUN pip install --no-cache-dir uv && \ uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ - uv pip install --system --no-deps -r requirements.txt + uv pip install --system --no-deps -r requirements.txt && \ + uv pip install --system --no-deps "maturin>=1.12.6,<2" # Copy the source and the non-src force-include paths (see pyproject # [tool.hatch.build.targets.wheel.force-include]) before building the project. COPY src/ src/ +COPY rust/ rust/ COPY scripts/install scripts/install COPY deploy/windows deploy/windows @@ -36,7 +52,15 @@ COPY deploy/windows deploy/windows COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/ # Install the project itself without re-resolving dependencies. -RUN uv pip install --system --no-deps . +RUN uv pip install --system --no-deps . && \ + maturin build --release \ + --manifest-path rust/crates/openjarvis-python/Cargo.toml \ + --interpreter python3 \ + --out /tmp/openjarvis-rust-wheel && \ + uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \ + python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \ + python3 -m pip uninstall -y maturin && \ + rm -rf /tmp/openjarvis-rust-wheel rust # Stage 3: Runtime FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf diff --git a/deploy/docker/Dockerfile.gpu b/deploy/docker/Dockerfile.gpu index 3e37afd3..505461e6 100644 --- a/deploy/docker/Dockerfile.gpu +++ b/deploy/docker/Dockerfile.gpu @@ -4,20 +4,37 @@ # Stage 1: Build frontend SPA FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend +# Public Supabase anon key for the savings leaderboard; empty by default so +# the image's leaderboard stays disabled (#589). Pass --build-arg to enable. +ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON= WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ RUN npm ci --ignore-scripts 2>/dev/null || npm install COPY frontend/ . -RUN npm run build +RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build # Stage 2: Build Python package (NVIDIA CUDA 12.4) FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \ + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + python3 \ + python3-dev \ + python3-pip \ + python3-venv && \ rm -rf /var/lib/apt/lists/* +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain none && \ + rustup toolchain install 1.88 --profile minimal && \ + rustup default 1.88 + WORKDIR /app # Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile @@ -25,15 +42,25 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md ./ RUN pip install --no-cache-dir uv && \ uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ - uv pip install --system --no-deps -r requirements.txt + uv pip install --system --no-deps -r requirements.txt && \ + uv pip install --system --no-deps "maturin>=1.12.6,<2" COPY src/ src/ +COPY rust/ rust/ COPY scripts/install scripts/install COPY deploy/windows deploy/windows COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/ -RUN uv pip install --system --no-deps . +RUN uv pip install --system --no-deps . && \ + maturin build --release \ + --manifest-path rust/crates/openjarvis-python/Cargo.toml \ + --interpreter python3 \ + --out /tmp/openjarvis-rust-wheel && \ + uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \ + python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \ + python3 -m pip uninstall -y maturin && \ + rm -rf /tmp/openjarvis-rust-wheel rust # Stage 3: Runtime FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 diff --git a/deploy/docker/Dockerfile.gpu.rocm b/deploy/docker/Dockerfile.gpu.rocm index ddbef492..e3d9b080 100644 --- a/deploy/docker/Dockerfile.gpu.rocm +++ b/deploy/docker/Dockerfile.gpu.rocm @@ -4,20 +4,37 @@ # Stage 1: Build frontend SPA FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend +# Public Supabase anon key for the savings leaderboard; empty by default so +# the image's leaderboard stays disabled (#589). Pass --build-arg to enable. +ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON= WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ RUN npm ci --ignore-scripts 2>/dev/null || npm install COPY frontend/ . -RUN npm run build +RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build # Stage 2: Build Python package (AMD ROCm 7.2) FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder RUN apt-get update && \ - apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \ + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + python3 \ + python3-dev \ + python3-pip \ + python3-venv && \ rm -rf /var/lib/apt/lists/* +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain none && \ + rustup toolchain install 1.88 --profile minimal && \ + rustup default 1.88 + WORKDIR /app # Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile @@ -25,15 +42,25 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md ./ RUN pip install --no-cache-dir uv && \ uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ - uv pip install --system --no-deps -r requirements.txt + uv pip install --system --no-deps -r requirements.txt && \ + uv pip install --system --no-deps "maturin>=1.12.6,<2" COPY src/ src/ +COPY rust/ rust/ COPY scripts/install scripts/install COPY deploy/windows deploy/windows COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/ -RUN uv pip install --system --no-deps . +RUN uv pip install --system --no-deps . && \ + maturin build --release \ + --manifest-path rust/crates/openjarvis-python/Cargo.toml \ + --interpreter python3 \ + --out /tmp/openjarvis-rust-wheel && \ + uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \ + python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \ + python3 -m pip uninstall -y maturin && \ + rm -rf /tmp/openjarvis-rust-wheel rust # Stage 3: Runtime FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 diff --git a/deploy/docker/Dockerfile.sandbox b/deploy/docker/Dockerfile.sandbox index a57f28a7..07af2ffd 100644 --- a/deploy/docker/Dockerfile.sandbox +++ b/deploy/docker/Dockerfile.sandbox @@ -7,20 +7,18 @@ # image digest is the integrity check, and the copy is architecture-agnostic. FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node -FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder -# libstdc++6 + ca-certificates are the only runtime requirements of the Node -# binary copied below (the python slim image already provides libc/libgcc). RUN apt-get update && \ - apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \ + apt-get install -y --no-install-recommends build-essential ca-certificates curl && \ rm -rf /var/lib/apt/lists/* -# Transplant the Node.js runtime from the official image. Both images are Debian -# bookworm, so the glibc/libstdc++ ABI matches. -COPY --from=node /usr/local/bin/node /usr/local/bin/node -COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules -RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ - ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain none && \ + rustup toolchain install 1.88 --profile minimal && \ + rustup default 1.88 WORKDIR /app @@ -31,12 +29,41 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md ./ RUN pip install --no-cache-dir uv && \ uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ - uv pip install --system --no-deps -r requirements.txt + uv pip install --system --no-deps -r requirements.txt && \ + uv pip install --system --no-deps "maturin>=1.12.6,<2" COPY . . # Install the project itself without re-resolving dependencies. -RUN uv pip install --system --no-deps . +RUN uv pip install --system --no-deps . && \ + maturin build --release \ + --manifest-path rust/crates/openjarvis-python/Cargo.toml \ + --interpreter python3 \ + --out /tmp/openjarvis-rust-wheel && \ + uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \ + python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \ + python3 -m pip uninstall -y maturin && \ + rm -rf /tmp/openjarvis-rust-wheel rust/target + +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf + +# libstdc++6 + ca-certificates are the only runtime requirements of the Node +# binary copied below (the python slim image already provides libc/libgcc). +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/local /usr/local +COPY --from=builder /app /app + +# Transplant the Node.js runtime from the official image. Both images are Debian +# bookworm, so the glibc/libstdc++ ABI matches. +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + +WORKDIR /app LABEL openjarvis-sandbox=true diff --git a/tests/deployment/test_docker.py b/tests/deployment/test_docker.py index dbd5ef21..18192c93 100644 --- a/tests/deployment/test_docker.py +++ b/tests/deployment/test_docker.py @@ -123,6 +123,35 @@ class TestDockerFiles: assert "jarvis:" in content assert "ollama:" in content + def test_dockerfiles_build_native_rust_extension(self): + build_dockerfiles = [ + "Dockerfile", + "Dockerfile.gpu", + "Dockerfile.gpu.rocm", + "Dockerfile.sandbox", + ] + required_markers = [ + "rustup toolchain install 1.88", + "maturin build --release", + "rust/crates/openjarvis-python/Cargo.toml", + "/tmp/openjarvis-rust-wheel/*.whl", + "import openjarvis_rust", + ] + + for name in build_dockerfiles: + content = (DOCKER_DIR / name).read_text() + for marker in required_markers: + assert marker in content, ( + f"{name}: missing native build marker {marker!r}" + ) + + for name in ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]: + content = (DOCKER_DIR / name).read_text() + assert "COPY rust/ rust/" in content, f"{name}: rust workspace not copied" + assert content.index("COPY rust/ rust/") < content.index( + "maturin build --release" + ), f"{name}: rust workspace copied after native build" + def test_systemd_service_exists(self): assert (SYSTEMD_DIR / "openjarvis.service").is_file()