Files

182 lines
9.4 KiB
Docker

# syntax=docker/dockerfile:1
#
# One OpenSwarm workflow run, then exit. Build context is the REPO ROOT, not this
# directory, because the image needs backend/ and requirements.lock:
#
# docker build --platform linux/amd64 -f openswarm-runner/Dockerfile -t openswarm-runner .
#
# Layout the image commits to (all three are load-bearing, backend code resolves
# them with zero changes when OPENSWARM_PACKAGED=1):
# /app/backend the FastAPI orchestrator
# /app/router 9router's standalone server, found by p_find_9router_dir()
# /app/python-env UV_PYTHON target probed by tools_lib/mcp_config.py
#
# Plus the renderer half, which exists so browser tools work the way they do on a
# laptop instead of being denied:
# /app/electron-runtime the same CastLabs Electron build the desktop app ships
# /app/electron the desktop shell's own main process, unmodified
# /app/frontend the production webpack bundle, served off loopback
#
# amd64 only. CastLabs publishes no linux-arm64 build, and running a DIFFERENT
# Electron than the desktop app ships would quietly undo the point of this image.
ARG PYTHON_VERSION=3.13
ARG NODE_VERSION=20
ARG ROUTER_VERSION=0.3.60
ARG UV_VERSION=0.11.8
# Must track electron/package.json's devDependency, or the container drives a different browser than the laptop does.
ARG ELECTRON_VERSION=42.3.3+wvcus
ARG ELECTRON_SHA256=5b6ce3a4d13f07fc63d79e884f6a40d1bc8a1cdf82cb2d130c26e8c1530649cb
FROM node:${NODE_VERSION}-bookworm-slim AS node
# 9router 0.3.60 is pure JavaScript; --ignore-scripts skips a postinstall that only rebuilds a native addon the standalone server never loads.
FROM node AS router
ARG ROUTER_VERSION
WORKDIR /stage
RUN printf '{"name":"router-stage","version":"0.0.0","private":true}\n' > package.json \
&& npm install "9router@${ROUTER_VERSION}" --no-save --no-audit --no-fund --silent --ignore-scripts \
&& test -f node_modules/9router/app/server.js \
&& test -z "$(find node_modules/9router -name '*.node' -print -quit)"
# The App Builder's template dependencies, installed once here and shipped ALREADY EXTRACTED at
# the digest path backend/apps/outputs/view_builder_templates.py already probes. Without it the
# first CreateApp in a run pays a cold npm install against the public registry, and a run with no
# egress just fails. NOT $BUILDPLATFORM: vite pulls in a platform-specific esbuild, so this has to
# resolve on the arch the container will actually run on.
FROM node:${NODE_VERSION}-bookworm-slim AS webapp-template
WORKDIR /stage
COPY backend/apps/outputs/webapp_template/frontend/package.json ./package.json
RUN set -eux; \
npm install --no-audit --no-fund --loglevel=error --ignore-scripts; \
test -x node_modules/.bin/vite; \
digest="$(sha256sum package.json | cut -c1-12)"; \
mkdir -p "/out/${digest}"; \
mv node_modules "/out/${digest}/node_modules"
# Webpack output is architecture-independent, so this runs natively on the build host rather than under emulation.
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS frontend
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund --silent
COPY frontend ./
RUN npm run build && test -f dist/index.html
# The shell's runtime deps only. --ignore-scripts leaves uiohook-napi without its prebuilt addon, which is correct: it taps a real keyboard, there isn't one here, and voiceHotkey already requires it inside a try.
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS shell-deps
WORKDIR /stage
COPY electron/package.json electron/package-lock.json ./
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund --silent
FROM debian:bookworm-slim AS electron
ARG ELECTRON_VERSION
ARG ELECTRON_SHA256
ARG TARGETARCH
RUN set -eux; \
test "${TARGETARCH}" = "amd64" || { echo "the renderer half is amd64-only: CastLabs ships no linux-${TARGETARCH} Electron" >&2; exit 1; }; \
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates unzip; \
url="https://github.com/castlabs/electron-releases/releases/download/v${ELECTRON_VERSION}/electron-v${ELECTRON_VERSION}-linux-x64.zip"; \
curl -fsSL -o /tmp/electron.zip "${url}"; \
echo "${ELECTRON_SHA256} /tmp/electron.zip" | sha256sum -c -; \
mkdir -p /stage; \
unzip -q /tmp/electron.zip -d /stage; \
rm /tmp/electron.zip; \
test -x /stage/electron
FROM python:${PYTHON_VERSION}-slim-bookworm AS uv
ARG UV_VERSION
ARG TARGETARCH
RUN set -eux; \
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates; \
case "${TARGETARCH}" in \
amd64) triple=x86_64-unknown-linux-gnu; sha=56dd1b66701ecb62fe896abb919444e4b83c5e8645cca953e6ddd496ff8a0feb ;; \
arm64) triple=aarch64-unknown-linux-gnu; sha=eee8dd658d20e5ac85fec9c2326b6cbc9d83a1eef09ef07433e58698ac849591 ;; \
*) echo "unsupported TARGETARCH ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL -o /tmp/uv.tar.gz "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${triple}.tar.gz"; \
echo "${sha} /tmp/uv.tar.gz" | sha256sum -c -; \
mkdir -p /stage; \
tar -xzf /tmp/uv.tar.gz -C /stage --strip-components=1
# Wheels only: the runtime image ships no compiler, so a source build here is a build-time failure rather than a 3am surprise.
FROM python:${PYTHON_VERSION}-slim-bookworm AS pydeps
COPY backend/requirements.lock /tmp/requirements.lock
RUN pip install --no-cache-dir --require-hashes --only-binary=:all: \
--prefix=/opt/pydeps -r /tmp/requirements.lock
FROM python:${PYTHON_VERSION}-slim-bookworm
# The X server plus every shared object `ldd` reports the Electron binary wanting, and the fonts without which every page renders as boxes. Derived from ldd on the real binary, not from a blog post.
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
git ca-certificates \
xvfb fonts-liberation \
libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 \
libdbus-1-3 libdrm2 libexpat1 libgbm1 libglib2.0-0 libgtk-3-0 libnss3 \
libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 \
libxfixes3 libxkbcommon0 libxrandr2 libxtst6; \
rm -rf /var/lib/apt/lists/*
COPY --from=node /usr/local/bin/node /usr/local/bin/node
# npm and npx too, not just node. They are shims into lib/node_modules, so copying the tree and
# re-linking is the only way to get them; a `node` with no `npm` is what left the App Builder
# scaffolding an app it could never install, build or serve.
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=pydeps /opt/pydeps /usr/local
# Numeric owner on every /app copy, because a `chown -R /app` afterwards rewrites the whole tree into a second layer and the image pays for it twice (that cost 493MB before this line existed). Numeric, not `runner`, because the user is created further down.
COPY --from=router --chown=10001:10001 /stage/node_modules/9router/app /app/router
COPY --chown=10001:10001 backend /app/backend
COPY --chown=10001:10001 openswarm-runner/runner /app/runner
COPY --chown=10001:10001 electron /app/electron
COPY --from=shell-deps --chown=10001:10001 /stage/node_modules /app/electron/node_modules
COPY --from=electron --chown=10001:10001 /stage /app/electron-runtime
COPY --from=frontend --chown=10001:10001 /src/dist /app/frontend
# After backend/, never before: mcp_config.resolve_command probes uv-bin last, and the repo's own copy is Mach-O.
COPY --from=uv --chown=10001:10001 /stage/uv /app/backend/uv-bin/uv
COPY --from=uv --chown=10001:10001 /stage/uvx /app/backend/uv-bin/uvx
# Also after backend/, and at the exact path bundled_extracted_modules() looks for.
COPY --from=webapp-template --chown=10001:10001 /out /app/backend/apps/outputs/webapp_template_cache
RUN set -eux; \
if ls /app/backend/.env* >/dev/null 2>&1; then echo "a dotenv reached the image; fix Dockerfile.dockerignore" >&2; exit 1; fi; \
ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
npm --version >/dev/null; \
mkdir -p /app/python-env/bin; \
ln -s /usr/local/bin/python3 /app/python-env/bin/python3; \
find /app/backend -name '__pycache__' -type d -prune -exec rm -rf {} +; \
useradd --create-home --uid 10001 --shell /usr/sbin/nologin runner; \
mkdir -p /data; \
ln -s /data/openswarm /app/backend/data; \
mkdir -p /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown runner:runner /data; \
printf '[user]\n\tname = OpenSwarm Cloud Run\n\temail = cloud-run@openswarm.local\n[init]\n\tdefaultBranch = main\n[safe]\n\tdirectory = *\n' > /etc/gitconfig
USER runner
WORKDIR /app
# RUNNER_MAX_RUN_SECONDS lives in the IMAGE, not in fly.toml: machines are created one
# per run through the Machines API, which ignores fly.toml's [env], so a cap defined
# there would silently not apply to the only machines that ever run a workflow.
ENV HOME=/home/runner \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
RUNNER_MAX_RUN_SECONDS=1800 \
OPENSWARM_HEADLESS=1 \
OPENSWARM_PACKAGED=1 \
OPENSWARM_DATA_ROOT=/data/openswarm \
OPENSWARM_HOST=127.0.0.1 \
OPENSWARM_PORT=8324 \
DATA_DIR=/data/9router \
NODE_ENV=production \
ELECTRON_BIN=/app/electron-runtime/electron \
OPENSWARM_RUN_WORKSPACE=/data/workspace \
OPENSWARM_NODE_PATH=/usr/local/bin/node
ENTRYPOINT ["python3", "-m", "runner.main"]