Files
pyfconfig/README.md
woggioni 4fa6c0cf2e Add self-contained GraalPy native executable
Build the app to a single native binary with graalpy's 'standalone
native' command (Dockerfile.native). granian/rloop cannot be bundled
into a standalone binary, so the executable serves the app over ASGI
with Hypercorn via native/entrypoint.py, which also works around
GraalPy standalone quirks (socket.listen, str response bodies, signal
handling).

Adds docker-compose.native.yml, a -native CI job, and README docs.
2026-09-24 11:11:00 +00:00

198 lines
7.9 KiB
Markdown

# pyfconfig
A clone of [ifconfig.me](https://ifconfig.me/) built on the
[kaya](https://gitea.woggioni.net/woggioni/kaya) framework
(`kaya-core` + `kaya-rsgi`), served by [Granian](https://github.com/emmett-framework/granian)
over the RSGI protocol.
## Stack
- **kaya-core** — routing and HTTP request/response handling
- **kaya-rsgi** — Granian (RSGI) adapter
- **kaya-forwarded** — trusted-proxy handling of `Forwarded` / `X-Forwarded-*` headers
- **granian** — application server
- **rloop** — Rust event loop used by Granian instead of the stdlib asyncio loop
- **httpx + pwo** — test client over kaya's ASGI transport
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.
A third image compiles the app to a **single self-contained native
executable** with GraalPy's `standalone native` command — see
[Dockerfile.native](Dockerfile.native) and the
[native executable](#native-executable) section. That build serves the app
over ASGI with [Hypercorn](https://hypercorn.readthedocs.io/) instead of
granian/rloop, because compiled extensions cannot be bundled into a standalone
binary.
## Endpoints
| Method | Path | Response |
|---|---|---|
| GET | `/` | HTML page for browsers (`Accept: text/html`), plain-text IP otherwise |
| GET | `/ip` | Client IP address |
| GET | `/ua` | `User-Agent` header |
| GET | `/lang` | `Accept-Language` header |
| GET | `/encoding` | `Accept-Encoding` header |
| GET | `/mime` | `Accept` header |
| GET | `/charset` | `Accept-Charset` header |
| GET | `/forwarded` | `X-Forwarded-For` header |
| GET | `/all` | All connection fields as `key: value` lines (empty fields included; `remote_host` shown as `unavailable`) |
| GET | `/all.json` | Same fields as a JSON object, empty fields omitted |
| GET | `/api/health` | Liveness probe (`{"status":"ok"}`) |
The `/all` field order mirrors the reference site: `ip_addr`,
`remote_host`, `user_agent`, `port`, `language`, `referer`, `connection`,
`keep_alive`, `method`, `encoding`, `mime`, `charset`, `via`, `forwarded`.
The reported client IP/port honor the `Forwarded`, `X-Forwarded-For`,
`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers **only when the
direct peer belongs to one of the `TRUSTED_PROXY_CIDRS`** (see
[Configuration](#configuration)); the header chain is walked right-to-left
skipping trusted proxies, so spoofed entries prepended by the client are
never selected. Without trusted proxies configured, or when the peer is
untrusted, the socket peer address is used.
Example:
```console
$ curl http://localhost:8080/
203.0.113.7
$ curl http://localhost:8080/all.json
{"ip_addr":"203.0.113.7","user_agent":"curl/8.21.0","port":"51342","method":"GET","mime":"*/*"}
```
## Running
### With Docker Compose (recommended)
```bash
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`).
### Native executable
The app can be compiled to a single self-contained executable (GraalPy runtime
+ standard library + dependencies + app, no Python installation required) with
GraalPy's `standalone native` command:
```bash
docker compose -f docker-compose.native.yml up -d --build
# App published at http://localhost:8080
```
The executable is built in [Dockerfile.native](Dockerfile.native) on top of
`ghcr.io/graalvm/graalpy-community:25` with the version-matched
`native-image` toolchain, and runs on a slim `oraclelinux:10-slim` base. It is
published as the `*-native` tags (e.g. `latest-native`, `0.1.0-native`).
Notes:
- The native build is memory-hungry: `native-image` needs several GB of RAM
(tunable with the `NATIVE_IMAGE_XMX` build arg, default `5500m`) and takes
roughly 8 minutes per build.
- It uses GraalPy's interpreter-only runtime (`-Os`) for a smaller binary and
a lighter build. Remove `-Os` in [Dockerfile.native](Dockerfile.native) to
enable the JIT at the cost of build time and memory.
- The native executable serves HTTP via Hypercorn over ASGI rather than
granian/rloop (see [native/entrypoint.py](native/entrypoint.py)).
- `HOST` / `PORT` configure the bind address (defaults `0.0.0.0:8080`).
### On the host
```bash
cp .env.example .env # optional; all settings have defaults
python -m venv .venv && . .venv/bin/activate
pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \
--extra-index-url https://pypi.org/simple \
-e .
granian --interface rsgi --loop rloop --host 0.0.0.0 --port 8000 pyfconfig.app:app
```
The `--loop rloop` flag (or `GRANIAN_LOOP=rloop`, already set in the
Docker image) makes Granian run the app on the
[rloop](https://github.com/gi0baro/rloop) Rust event loop instead of the
stdlib asyncio loop.
### Configuration
Environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `SITE_NAME` | `pyfconfig` | Public name used in the HTML page title and the curl examples (set to your domain, e.g. `ifconfig.example.com`) |
| `TRUSTED_PROXY_CIDRS` | *(empty)* | Comma-separated CIDRs/IPs of trusted reverse proxies (e.g. `127.0.0.1,10.0.0.0/8`). Forwarded headers are honored only from these peers; empty means no proxy is trusted |
The bind address is configured through Granian itself (`GRANIAN_HOST` /
`GRANIAN_PORT` env vars or `--host` / `--port` CLI flags).
## Tests
```bash
. .venv/bin/activate
python -m unittest discover -s tests -t .
```
The tests drive the app in-process through kaya's ASGI interface using
`httpx.ASGITransport`; no external services are needed.
## Project layout
```
src/pyfconfig/
├── app.py # KayaApp assembly; imports route modules
├── config.py # env-driven Settings dataclass
├── page.py # HTML landing page builder (kaya has no templating)
└── routes/
├── health.py # GET /api/health
├── root.py # GET / with content negotiation
└── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json
tests/
└── test_routes.py # unittest + httpx.ASGITransport
native/
└── entrypoint.py # Hypercorn ASGI entry point for the native executable
```
Route modules import the module-level `app` from `pyfconfig.app` and
register handlers with `@app.GET(...)` decorators at import time —
`pyfconfig.app` imports them last to complete the wiring.
## Deployment
Three 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).
- `Dockerfile.native` — compiles the app to a single self-contained native
executable with GraalPy's `standalone native` command, served by Hypercorn
over ASGI; the runtime image is a slim `oraclelinux:10-slim`.
Pushing a `release/*` tag triggers the Gitea Actions workflow in
`.gitea/workflows/build.yaml`, which builds and publishes all three images to
`gitea.woggioni.net/woggioni/pyfconfig`: the CPython image as `latest` /
`<version>`, the GraalPy image as `latest-graalpy` / `<version>-graalpy`, and
the native executable as `latest-native` / `<version>-native`.