From 609d9e22a81f6a2c1b9e211bafd9d6e9654e8585 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 24 Sep 2026 01:54:02 +0000 Subject: [PATCH] Add GraalPy 25 image replacing CPython Multi-stage Dockerfile.graalpy on ghcr.io/graalvm/graalpy-community:25 that builds the granian and rloop PyO3 extensions from source against GraalPy (no GraalPy wheels exist), installs the pure-Python deps, and serves the unchanged RSGI app on the rloop event loop. Adds docker-compose.graalpy.yml and a CI job publishing the image as the *-graalpy tags on release/*. --- .dockerignore | 2 + .gitea/workflows/build.yaml | 35 +++++++++++++++ Dockerfile.graalpy | 86 +++++++++++++++++++++++++++++++++++++ README.md | 36 ++++++++++++++-- docker-compose.graalpy.yml | 23 ++++++++++ 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.graalpy create mode 100644 docker-compose.graalpy.yml diff --git a/.dockerignore b/.dockerignore index c0ba0da..64f76b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,8 @@ dist/ build/ tests/ Dockerfile +Dockerfile.graalpy .dockerignore opencode.json docker-compose.yml +docker-compose.graalpy.yml diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 62f0f83..e4f83ba 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -36,3 +36,38 @@ jobs: push: true pull: true tags: ${{ steps.meta.outputs.tags }} + + build_and_push_graalpy_docker_image: + name: "Build and push GraalPy docker image" + runs-on: hostinger + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Gitea container registry + uses: docker/login-action@v3 + with: + registry: gitea.woggioni.net + username: woggioni-opencode-agent + password: ${{ secrets.PUBLISHER_TOKEN }} + - name: Extract metadata for GraalPy image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: gitea.woggioni.net/woggioni/pyfconfig + flavor: | + latest=false + suffix=-graalpy + tags: | + type=match,pattern=release/(.*),group=1 + type=raw,value=latest + - name: Build and push GraalPy docker image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.graalpy + platforms: linux/amd64 + push: true + pull: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile.graalpy b/Dockerfile.graalpy new file mode 100644 index 0000000..c6135f5 --- /dev/null +++ b/Dockerfile.graalpy @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 +# Multi-stage production build for pyfconfig running on GraalPy 25 instead of +# CPython. Base: ghcr.io/graalvm/graalpy-community:25 (GraalPy 25.0.2 / +# Python 3.12.8, Oracle Linux 9 slim). +# +# GraalPy is not ABI-compatible with CPython wheels, so the two compiled +# dependencies (granian and rloop, both PyO3/Rust extensions) have to be built +# from source against the GraalPy interpreter. Everything else in +# requirements.txt is pure Python and installs normally. +# +# The Rust toolchain and build artifacts live only in the builder stage and are +# discarded in the runtime image. BuildKit cache mounts (type=cache) keep the +# cargo registry and target dir across builds without baking them into layers; +# requires BuildKit (default for `docker build` / `docker buildx` on modern +# daemons). + +ARG GRAALPY_IMAGE=ghcr.io/graalvm/graalpy-community:25 + +# --- Builder --------------------------------------------------------------- +FROM ${GRAALPY_IMAGE} AS builder + +# Rust toolchain for building the PyO3-based extensions. Keep the registry and +# target dir in BuildKit cache mounts so rebuilds are cheap. +# /opt/venv/bin must be on PATH so pip's maturin build backend can find the +# maturin executable; /root/.cargo/bin provides cargo/rustc. +ENV CARGO_HOME=/root/.cargo \ + RUSTUP_HOME=/root/.rustup \ + CARGO_TARGET_DIR=/build/target \ + PATH=/opt/venv/bin:/root/.cargo/bin:${PATH} + +RUN --mount=type=cache,target=/root/.cargo/registry \ + --mount=type=cache,target=/root/.cargo/git \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --no-modify-path + +WORKDIR /build + +# Use GraalPy's own pip: it is patched for native-package compatibility and +# preconfigured with GraalPy wheel repositories, so it must not be upgraded. +RUN graalpy -m venv /opt/venv \ + && /opt/venv/bin/pip install maturin + +COPY pyproject.toml README.md requirements.txt ./ +COPY src/ ./src/ + +# Build the Rust extensions from their sdists against GraalPy first (forcing +# source builds with --no-build-isolation so the preinstalled maturin is used), +# then the pure-Python deps, then the project itself. granian/rloop are already +# satisfied when requirements.txt is installed. +RUN --mount=type=cache,target=/root/.cargo/registry \ + --mount=type=cache,target=/root/.cargo/git \ + --mount=type=cache,target=/build/target \ + /opt/venv/bin/pip install --no-build-isolation \ + --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \ + --extra-index-url https://pypi.org/simple \ + granian==2.8.2 rloop==0.5.0 \ + && /opt/venv/bin/pip install \ + --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \ + --extra-index-url https://pypi.org/simple \ + -r requirements.txt \ + && /opt/venv/bin/pip install --no-deps . + +# --- Runtime --------------------------------------------------------------- +FROM ${GRAALPY_IMAGE} + +RUN useradd -r -s /sbin/nologin -d /home/app -m app + +COPY --from=builder /opt/venv /opt/venv + +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HOME=/home/app \ + GRANIAN_HOST=0.0.0.0 \ + GRANIAN_PORT=8080 \ + GRANIAN_INTERFACE=rsgi \ + GRANIAN_LOOP=rloop + +USER app + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1 + +CMD ["granian", "pyfconfig.app:app"] diff --git a/README.md b/README.md index f7aefe6..6de248f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ over the RSGI protocol. No database, sessions, or authentication — the app is stateless. +A second image runs the exact same app on [GraalPy](https://www.graalvm.org/python/) +(GraalVM Community Edition 25) instead of CPython — see +[Dockerfile.graalpy](Dockerfile.graalpy) and the +[GraalPy image](#graalpy-image) section. Because GraalPy is not ABI-compatible +with CPython wheels, the compiled `granian` and `rloop` extensions are built +from source against GraalPy during the image build. + ## Endpoints | Method | Path | Response | @@ -62,6 +69,19 @@ docker compose up -d --build # App published at http://localhost:8080 ``` +### GraalPy image + +An equivalent image based on `ghcr.io/graalvm/graalpy-community:25` +(GraalPy 25.0.2 / Python 3.12.8) is available via a separate compose file: + +```bash +docker compose -f docker-compose.graalpy.yml up -d --build +# App published at http://localhost:8080 +``` + +This image is published to the registry as the `*-graalpy` tags +(e.g. `latest-graalpy`, `0.1.0-graalpy`). + ### On the host ```bash @@ -121,7 +141,15 @@ register handlers with `@app.GET(...)` decorators at import time — ## Deployment -The `Dockerfile` is a multi-stage alpine build producing a minimal, -non-root runtime image; pushing a `release/*` tag triggers the Gitea -Actions workflow in `.gitea/workflows/build.yaml`, which builds and -publishes the image to `gitea.woggioni.net/woggioni-opencode-agent/pyfconfig`. +Two images are built from this repository: + +- `Dockerfile` — multi-stage alpine build on CPython, producing a minimal, + non-root runtime image. +- `Dockerfile.graalpy` — multi-stage Oracle Linux build on GraalPy 25, where + the compiled `granian` and `rloop` extensions are compiled from source + against GraalPy (the Rust toolchain stays in the builder stage). + +Pushing a `release/*` tag triggers the Gitea Actions workflow in +`.gitea/workflows/build.yaml`, which builds and publishes both images to +`gitea.woggioni.net/woggioni/pyfconfig`: the CPython image as `latest` / +``, and the GraalPy image as `latest-graalpy` / `-graalpy`. diff --git a/docker-compose.graalpy.yml b/docker-compose.graalpy.yml new file mode 100644 index 0000000..45be6ce --- /dev/null +++ b/docker-compose.graalpy.yml @@ -0,0 +1,23 @@ +# GraalPy variant of the app. Standalone file (not an override) so it can be +# run alongside the default compose file: +# +# docker compose -f docker-compose.graalpy.yml up -d --build +# +# The app runs on GraalPy 25 instead of CPython; see Dockerfile.graalpy. +services: + pyfconfig-graalpy: + build: + context: . + dockerfile: Dockerfile.graalpy + image: pyfconfig-graalpy:latest + restart: unless-stopped + environment: + # Public name shown in the HTML page and the curl examples; set to the + # deployment domain (e.g. "ifconfig.example.com"). + SITE_NAME: ${SITE_NAME:-pyfconfig} + # Comma-separated CIDRs of trusted reverse proxies (e.g. + # "172.16.0.0/12"); required for correct client IPs when deployed + # behind a reverse proxy. Empty means no proxy is trusted. + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-} + ports: + - "127.0.0.1:8080:8080"