feat(deploy): rootless podman + Quadlet deployment (forgejo-mcp-broker-8yd)
Adds a multi-stage Containerfile, Quadlet unit, and operator walkthrough for a production deploy. The broker spawns forgejo-mcp per session, so the image bundles both binaries — broker built from this repo, forgejo-mcp pinned via FORGEJO_MCP_VERSION build-arg (default 2.18.0). Image stages: 1. golang:alpine compiles the broker with ldflags-stamped buildinfo 2. golang:alpine clones forgejo-mcp at the pinned tag and compiles it 3. distroless static-nonroot copies both binaries; uid 65532 Persistent state via the named volume `fjmcp-state` mounted at /data. SQLite WAL + SHM sidecars live alongside broker.db on the same volume, so a container swap or image upgrade preserves all OAuth clients, issued tokens, and refresh-token history. Verified end-to-end: podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, broker.db created podman stop fjmcp-test podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, same broker.db ls volume → broker.db, broker.db-shm, broker.db-wal all present Quadlet unit (deploy/podman/fjmcp-broker.container) drops into ~/.config/containers/systemd/, reads secrets from a 0600 env file outside the unit, publishes :8080 on loopback for Caddy to front. Makefile gains `image` and `image-run` targets. README links to the new docs/deploy-podman.md walkthrough. Closes forgejo-mcp-broker-8yd. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8369ec2cc7
commit
018f56a4ad
7 changed files with 343 additions and 3 deletions
169
docs/deploy-podman.md
Normal file
169
docs/deploy-podman.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Deploying fjmcp-broker with rootless podman
|
||||
|
||||
End-to-end walkthrough for a production deployment: build the image,
|
||||
configure secrets, start the container, persist state across restarts,
|
||||
and upgrade to a newer image without losing data.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Podman 4.4 or later (Quadlet support).
|
||||
- A reachable hostname with TLS (Caddy handles this — see
|
||||
`docs/deploy-caddy.md` for the front-end half).
|
||||
- A Forgejo instance you control, with permission to register an OAuth2
|
||||
application.
|
||||
|
||||
## 1. Register the Forgejo OAuth application
|
||||
|
||||
The broker authenticates users by delegating to your Forgejo instance's
|
||||
OAuth2 provider.
|
||||
|
||||
1. Sign in to Forgejo as the operator who should own the integration.
|
||||
2. **Settings → Applications → OAuth2 Applications → Create
|
||||
application**.
|
||||
3. Redirect URI: `https://<your-broker-hostname>/oauth/callback`.
|
||||
4. Save the issued `client_id` and `client_secret`.
|
||||
|
||||
## 2. Build the image
|
||||
|
||||
The Containerfile bundles two binaries — `fjmcp-broker` (this repo) and
|
||||
a pinned version of `forgejo-mcp` — into a distroless static image.
|
||||
|
||||
```bash
|
||||
podman build \
|
||||
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--build-arg GIT_REVISION="$(git describe --always --dirty)" \
|
||||
--build-arg FORGEJO_MCP_VERSION="2.18.0" \
|
||||
-t ghcr.io/olemd/fjmcp-broker:latest \
|
||||
.
|
||||
```
|
||||
|
||||
Pin `FORGEJO_MCP_VERSION` to a tag — `latest` would mean image
|
||||
reproducibility depends on the upstream HEAD at build time. The broker
|
||||
spawns this binary per session, so version drift is the operator's
|
||||
problem to track.
|
||||
|
||||
## 3. Configure the environment
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config
|
||||
cp deploy/podman/fjmcp-broker.env.example ~/.config/fjmcp-broker.env
|
||||
chmod 0600 ~/.config/fjmcp-broker.env
|
||||
$EDITOR ~/.config/fjmcp-broker.env
|
||||
```
|
||||
|
||||
Required values:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `FJMCP_BROKER_PUBLIC_URL` | What clients see (e.g. `https://mcp.example.com`) |
|
||||
| `FORGEJO_URL` | Upstream Forgejo instance |
|
||||
| `FORGEJO_OAUTH_CLIENT_ID` | From step 1 |
|
||||
| `FORGEJO_OAUTH_CLIENT_SECRET` | From step 1 |
|
||||
|
||||
The broker derives discovery URLs (`/.well-known/...`) from
|
||||
`FJMCP_BROKER_PUBLIC_URL`, never from the inbound `Host` header — set
|
||||
this to exactly the hostname Caddy serves.
|
||||
|
||||
## 4. Start under systemd via Quadlet
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/containers/systemd
|
||||
cp deploy/podman/fjmcp-broker.container ~/.config/containers/systemd/
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start fjmcp-broker
|
||||
systemctl --user status fjmcp-broker
|
||||
```
|
||||
|
||||
Quadlet creates the `fjmcp-state` named volume on first start. The
|
||||
SQLite store and its WAL/SHM sidecars all live there.
|
||||
|
||||
Smoke-test:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8080/healthz | jq .
|
||||
```
|
||||
|
||||
A healthy response includes `{"status":"ok","store":"ok",…}`. Wire
|
||||
Caddy in front next; see `docs/deploy-caddy.md`.
|
||||
|
||||
## 5. Persist state across image upgrades
|
||||
|
||||
The point of the named volume is that you can replace the container
|
||||
entirely and keep your data. To upgrade:
|
||||
|
||||
```bash
|
||||
# Pull / build the new image.
|
||||
podman pull ghcr.io/olemd/fjmcp-broker:latest
|
||||
|
||||
# Restart — Quadlet recreates the container against the new image.
|
||||
systemctl --user restart fjmcp-broker
|
||||
```
|
||||
|
||||
The `fjmcp-state` volume is detached from container lifecycle. Any
|
||||
registered OAuth clients (`/oauth/register` results), issued tokens,
|
||||
and refresh-token history survive.
|
||||
|
||||
To verify mid-upgrade:
|
||||
|
||||
```bash
|
||||
# Before upgrade
|
||||
podman exec fjmcp-broker /usr/local/bin/fjmcp-broker --version
|
||||
|
||||
# Inspect the SQLite store directly (via a temporary container).
|
||||
podman run --rm -v fjmcp-state:/data:ro \
|
||||
docker.io/library/alpine ls -l /data/
|
||||
|
||||
# After upgrade, confirm the same broker.db is in place.
|
||||
```
|
||||
|
||||
The migration runner is idempotent — re-opening the same database is a
|
||||
no-op for already-applied migrations, so a downgrade-then-upgrade
|
||||
across compatible schema versions works too.
|
||||
|
||||
## 6. Backups
|
||||
|
||||
`fjmcp-state` contains user-mapped credentials. Treat it as sensitive:
|
||||
|
||||
```bash
|
||||
# Online backup using SQLite's .backup command.
|
||||
podman exec fjmcp-broker /usr/local/bin/sqlite3 \
|
||||
/data/broker.db ".backup /data/backup.db"
|
||||
|
||||
podman cp fjmcp-broker:/data/backup.db ./broker-$(date -u +%Y%m%d).db
|
||||
```
|
||||
|
||||
Encrypt backups at rest. The broker stores Forgejo access tokens in
|
||||
cleartext (it has to, to spawn subprocesses with them in env); a leaked
|
||||
backup gives an attacker every user's upstream token until each
|
||||
expires.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Healthz returns 503 with `store: "error: ..."`.** The SQLite file
|
||||
isn't reachable. Check volume mount and permissions:
|
||||
|
||||
```bash
|
||||
podman exec fjmcp-broker ls -l /data
|
||||
```
|
||||
|
||||
The container runs as uid 65532. If the volume was created with
|
||||
different ownership (e.g. you bind-mounted a host directory), `podman
|
||||
unshare chown -R 65532:65532 /path/to/host/dir`.
|
||||
|
||||
**`/oauth/authorize` redirects to a wrong host.** The broker echoes
|
||||
`FJMCP_BROKER_PUBLIC_URL` verbatim in OAuth metadata. Mismatched values
|
||||
between the broker and Caddy show up as redirects to the wrong domain.
|
||||
|
||||
**Container exits immediately with `config error`.** A required env
|
||||
var is missing or empty. The broker lists every missing field in its
|
||||
stderr:
|
||||
|
||||
```bash
|
||||
journalctl --user -u fjmcp-broker --since "5 minutes ago"
|
||||
```
|
||||
|
||||
**Forgejo callback fails with 404.** The Forgejo OAuth application's
|
||||
registered redirect URI doesn't match
|
||||
`${FJMCP_BROKER_PUBLIC_URL}/oauth/callback`. Trailing slashes and
|
||||
`http` vs `https` matter here.
|
||||
Loading…
Add table
Add a link
Reference in a new issue