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.
198 lines
7.9 KiB
Markdown
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`.
|