Compare commits

..
1 Commits
Author SHA1 Message Date
Latte 4acf683662 fix(ci): smoke-test the image over its bridge IP, not a published host port
CI / lint (pull_request) Successful in 56s
docker / lint (pull_request) Successful in 58s
docker / test (pull_request) Successful in 1m7s
CI / test (pull_request) Successful in 1m8s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Has been cancelled
Self-hosted runners can execute job steps as a sibling container sharing
the host's Docker socket, so -p 18080:8080 publishes on the runner host
rather than inside the step's own container — 127.0.0.1 never reaches it
(curl exit 7). Container-to-container traffic over the bridge network
always works, so resolve the smoke container's own IP and probe that.
2026-07-05 13:31:49 +02:00
4 changed files with 48 additions and 43 deletions

No files matched your search

+4 -4
View File
@@ -40,7 +40,7 @@ FAMILIAR_DATA_DIR_HOST=./data
# Writes off by default on a public instance. Set true to allow guarded writes.
FAMILIAR_ENABLE_WRITES=false
# --- Reverse proxy --------------------------------------------------------- #
# The container joins the external `proxy` network and exposes :8080; the proxy
# (Pangolin, or nginx/Caddy — see doc/deployment.md) terminates TLS and forwards
# to it. The public hostname and certs are configured in the proxy, not here.
# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ #
# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md.
FAMILIAR_DOMAIN=familiar.example.com
TRAEFIK_CERTRESOLVER=letsencrypt
+21 -31
View File
@@ -52,9 +52,6 @@ jobs:
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
# Per-commit container name: the self-hosted runner shares the host
# Docker daemon, so a fixed name collides across concurrent/re-run jobs.
echo "container=familiar-smoke-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT"
else
@@ -70,51 +67,44 @@ jobs:
run: |
set -euo pipefail
IMG="${{ steps.meta.outputs.sha_tag }}"
CONTAINER="${{ steps.meta.outputs.container }}"
TOKEN="smoke-$(date +%s)"
# Remove any container left behind by a killed/re-run job before we
# reuse the name (shared host daemon → stale containers can survive).
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
# 1. Runs as a non-root user (uid 1000).
uid=$(docker run --rm --entrypoint id "$IMG" -u)
echo "container uid: $uid"
[ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; }
# 2. Boot the server and probe it from *inside* its own container via
# `docker exec`. On self-hosted runners the job step runs in a container
# that shares the host's Docker socket but sits on a different network,
# so it can reach neither a published host port (-p lands on the runner
# host, not this step's netns) nor the server container's bridge IP
# (different network → connect hangs). `docker exec` goes through the
# socket and runs the probe in the server's own netns, where
# 127.0.0.1:8080 always works regardless of runner topology. The image
# ships python + httpx, so no curl or extra pull is needed.
docker run -d --name "$CONTAINER" -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG"
# probe "<httpx call returning a Response>" -> prints the status code
probe() { docker exec "$CONTAINER" python -c "import httpx; print(($1).status_code)"; }
# 2. Boot the server and probe it over HTTP. Talk to the container's own
# bridge IP rather than a host-published port (-p): self-hosted runners
# commonly execute steps as a sibling container sharing the host's
# Docker socket, in which case -p publishes on the *runner host*, not
# inside this step's container, and 127.0.0.1 never reaches it.
# Container-to-container traffic over the bridge network always works.
docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" -p 18080:8080 "$IMG"
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' familiar-smoke)
BASE="http://${ip}:8080"
for i in $(seq 1 30); do
if docker exec "$CONTAINER" python -c \
"import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then
break
fi
if curl -fsS "$BASE/healthz" >/dev/null 2>&1; then break; fi
sleep 1
done
echo "== /healthz (expect 200) =="
code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)")
[ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs "$CONTAINER"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/healthz")
[ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; }
echo "== MCP without token (expect 401) =="
code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{}})")
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/mcp" \
-H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}')
[ "$code" = "401" ] || { echo "::error::unauthenticated MCP returned $code"; exit 1; }
echo "== MCP with valid token (expect 200) =="
code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2025-06-18','capabilities':{},'clientInfo':{'name':'ci','version':'1'}}})")
[ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs "$CONTAINER"; exit 1; }
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/mcp" \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}')
[ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs familiar-smoke; exit 1; }
echo "Image smoke-test passed."
@@ -143,7 +133,7 @@ jobs:
if: always()
shell: bash
run: |
docker rm -f ${{ steps.meta.outputs.container }} || true
docker rm -f familiar-smoke || true
docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true
docker image prune -f || true
docker builder prune -f || true
+10 -5
View File
@@ -77,8 +77,8 @@ Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret `
```bash
cp .env.example .env
# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32),
# FAMILIAR_IMAGE (:latest prod / :dev dev channel)
# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32), FAMILIAR_DOMAIN,
# cert resolver, FAMILIAR_IMAGE (:latest prod / :dev dev channel)
docker network create proxy # once, if your reverse proxy doesn't own it yet
@@ -121,11 +121,16 @@ familiar.yourdomain.tld {
Caddy fetches and renews the certificate automatically and streams by default.
### Pangolin
### Traefik (labels included)
The compose service exposes `:8080` on the shared external `proxy` network and carries no proxy-specific labels. In Pangolin, add a resource for `familiar` that forwards to `http://familiar:8080` (target the container's `/mcp` path), and let Pangolin terminate HTTPS. Make sure Pangolin's own container is attached to the same `proxy` network so it can reach the service by name. Your public URL is then `https://familiar.yourdomain.tld/mcp`.
`docker-compose.yml` already carries Traefik router labels. Set in `.env`:
Because streamable HTTP holds a long-lived response open, raise the read/response timeout on the familiar resource and disable response buffering if the option is exposed (same reasoning as the nginx `proxy_read_timeout` / `proxy_buffering off` above).
```
FAMILIAR_DOMAIN=familiar.yourdomain.tld
TRAEFIK_CERTRESOLVER=letsencrypt
```
Traefik terminates HTTPS and forwards to the container's port 8080 on the `proxy` network. Your public URL is then `https://familiar.yourdomain.tld/mcp`.
## Operational notes
+13 -3
View File
@@ -60,11 +60,21 @@ services:
timeout: 10s
retries: 3
start_period: 10s
# No proxy labels: Pangolin discovers this service on the shared `proxy`
# network and forwards to :8080 — configure the route in Pangolin, not here.
labels:
# --- Traefik TLS termination (Let's Encrypt) -------------------------- #
# Enable if your reverse proxy is Traefik on the external `proxy` network.
# Set FAMILIAR_DOMAIN and TRAEFIK_CERTRESOLVER in .env. For nginx/Caddy
# instead, ignore these labels — see doc/deployment.md.
traefik.enable: "true"
traefik.docker.network: proxy
traefik.http.routers.familiar.rule: Host(`${FAMILIAR_DOMAIN:-familiar.example.com}`)
traefik.http.routers.familiar.entrypoints: websecure
traefik.http.routers.familiar.tls: "true"
traefik.http.routers.familiar.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-letsencrypt}
traefik.http.services.familiar.loadbalancer.server.port: "8080"
networks:
# Shared reverse-proxy network (Pangolin). Create once with:
# Shared reverse-proxy network (Traefik/Caddy). Create once with:
# docker network create proxy
proxy:
external: true