Compare commits

..
9 Commits
Author SHA1 Message Date
Latte 3aeb4a80d9 Merge pull request 'release: remote HTTP MCP — bearer + OAuth, Docker image, CI publish, per-client docs' (#65) from dev into main
CI / lint (push) Successful in 58s
docker / lint (push) Successful in 58s
CI / test (push) Successful in 1m7s
docker / test (push) Successful in 1m8s
docker / docker (push) Successful in 48s
CI / publish (push) Successful in 51s
Reviewed-on: #65
2026-07-05 15:50:52 +00:00
Latte d69c994da0 Merge pull request 'fix(ci): give the smoke-test container a per-commit name' (#68) from fix/smoke-test-container-name into dev
CI / lint (push) Successful in 55s
docker / lint (push) Successful in 57s
CI / test (push) Successful in 1m6s
docker / test (push) Successful in 1m6s
CI / lint (pull_request) Successful in 50s
CI / test (pull_request) Successful in 58s
docker / lint (pull_request) Successful in 52s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Successful in 1m18s
docker / test (pull_request) Successful in 1m1s
CI / publish (push) Successful in 54s
docker / docker (push) Successful in 48s
Reviewed-on: #68
2026-07-05 12:00:26 +00:00
Latte 288c696f28 fix(ci): give the smoke-test container a per-commit name
CI / lint (pull_request) Successful in 55s
docker / lint (pull_request) Successful in 56s
CI / test (pull_request) Successful in 1m6s
docker / test (pull_request) Successful in 1m4s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Successful in 41s
The Docker smoke-test ran the server container under the fixed name
familiar-smoke. On the self-hosted runner the job shares the host
Docker daemon, so a container left behind by a killed or re-run job
(or a concurrent dev push + PR) collides on that name and 'docker run'
fails with exit 125.

Derive the name from the commit SHA, remove any stale container of that
name before starting, and thread the name through the smoke-test and
cleanup steps.
2026-07-05 13:55:29 +02:00
Latte 6c4cbd13ad Merge pull request 'chore(deploy): drop Traefik labels, use Pangolin as the proxy' (#67) from chore/pangolin-proxy into dev
CI / lint (push) Successful in 51s
CI / test (push) Successful in 57s
docker / lint (push) Successful in 50s
docker / test (push) Successful in 57s
CI / lint (pull_request) Successful in 51s
docker / lint (pull_request) Successful in 51s
CI / test (pull_request) Successful in 1m0s
docker / test (pull_request) Successful in 57s
CI / publish (pull_request) Has been skipped
CI / publish (push) Successful in 53s
docker / docker (pull_request) Failing after 12s
docker / docker (push) Failing after 1m11s
Reviewed-on: #67
2026-07-05 11:46:47 +00:00
Latte aeb79fdbb7 Merge branch 'dev' into chore/pangolin-proxy
docker / test (pull_request) Successful in 1m1s
CI / lint (pull_request) Successful in 50s
docker / lint (pull_request) Successful in 50s
CI / test (pull_request) Successful in 1m2s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Failing after 22s
2026-07-05 11:46:40 +00:00
Latte 733757f22a Merge pull request 'fix(ci): smoke-test image from inside its container via docker exec' (#66) from fix/smoke-test-container-ip into dev
CI / lint (push) Successful in 55s
docker / lint (push) Successful in 57s
CI / test (push) Successful in 1m6s
docker / test (push) Successful in 1m5s
CI / lint (pull_request) Successful in 50s
docker / lint (pull_request) Successful in 53s
CI / test (pull_request) Successful in 1m1s
docker / test (pull_request) Successful in 1m0s
CI / publish (pull_request) Has been skipped
CI / publish (push) Successful in 55s
docker / docker (push) Failing after 44s
docker / docker (pull_request) Failing after 1m10s
Reviewed-on: #66
2026-07-05 11:46:33 +00:00
Latte 945e40b5d8 fix(ci): smoke-test the image from inside its own container via docker exec
CI / lint (pull_request) Successful in 56s
docker / lint (pull_request) Successful in 54s
CI / test (pull_request) Successful in 1m2s
CI / publish (pull_request) Has been skipped
docker / test (pull_request) Successful in 57s
docker / docker (pull_request) Successful in 41s
The smoke test probed the server over the network, which fails on the
self-hosted runner: the job step runs in a container that shares the host
Docker socket but sits on a different network, so it can reach neither a
published host port (-p lands on the runner host) nor the server
container's bridge IP (different network → curl connect hangs, so the job
looked stuck rather than failing).

Probe from inside the server container with `docker exec` instead: it goes
through the Docker socket and runs 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 image pull is needed. Drop the now
unnecessary -p publish.
2026-07-05 13:42:40 +02:00
Latte ef1567b071 chore(deploy): drop Traefik labels, document Pangolin as the proxy
CI / lint (pull_request) Successful in 53s
docker / lint (pull_request) Successful in 53s
CI / test (pull_request) Successful in 1m3s
CI / publish (pull_request) Has been skipped
docker / test (pull_request) Successful in 57s
docker / docker (pull_request) Failing after 1m7s
The compose service already joins the external `proxy` network and
exposes :8080; the reverse proxy owns TLS and routing. Remove the
Traefik router labels (and the now-unused FAMILIAR_DOMAIN /
TRAEFIK_CERTRESOLVER env vars) and document Pangolin — which discovers
the service on the shared network — as the proxy instead. nginx/Caddy
guidance is unchanged.
2026-07-05 13:38:51 +02:00
Latte 21956cfa56 Merge pull request 'Dev' (#61) from dev into main
CI / lint (push) Successful in 51s
CI / test (push) Successful in 58s
CI / publish (push) Successful in 50s
Reviewed-on: #61
2026-06-27 19:36:43 +00:00
4 changed files with 43 additions and 48 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. # Writes off by default on a public instance. Set true to allow guarded writes.
FAMILIAR_ENABLE_WRITES=false FAMILIAR_ENABLE_WRITES=false
# --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ # # --- Reverse proxy --------------------------------------------------------- #
# Only used if you run Traefik. For nginx/Caddy, see doc/deployment.md. # The container joins the external `proxy` network and exposes :8080; the proxy
FAMILIAR_DOMAIN=familiar.example.com # (Pangolin, or nginx/Caddy — see doc/deployment.md) terminates TLS and forwards
TRAEFIK_CERTRESOLVER=letsencrypt # to it. The public hostname and certs are configured in the proxy, not here.
+31 -21
View File
@@ -52,6 +52,9 @@ jobs:
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$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 if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT" echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT"
else else
@@ -67,44 +70,51 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
IMG="${{ steps.meta.outputs.sha_tag }}" IMG="${{ steps.meta.outputs.sha_tag }}"
CONTAINER="${{ steps.meta.outputs.container }}"
TOKEN="smoke-$(date +%s)" 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). # 1. Runs as a non-root user (uid 1000).
uid=$(docker run --rm --entrypoint id "$IMG" -u) uid=$(docker run --rm --entrypoint id "$IMG" -u)
echo "container uid: $uid" echo "container uid: $uid"
[ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; } [ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; }
# 2. Boot the server and probe it over HTTP. Talk to the container's own # 2. Boot the server and probe it from *inside* its own container via
# bridge IP rather than a host-published port (-p): self-hosted runners # `docker exec`. On self-hosted runners the job step runs in a container
# commonly execute steps as a sibling container sharing the host's # that shares the host's Docker socket but sits on a different network,
# Docker socket, in which case -p publishes on the *runner host*, not # so it can reach neither a published host port (-p lands on the runner
# inside this step's container, and 127.0.0.1 never reaches it. # host, not this step's netns) nor the server container's bridge IP
# Container-to-container traffic over the bridge network always works. # (different network → connect hangs). `docker exec` goes through the
docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" -p 18080:8080 "$IMG" # socket and runs the probe in the server's own netns, where
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' familiar-smoke) # 127.0.0.1:8080 always works regardless of runner topology. The image
BASE="http://${ip}:8080" # 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)"; }
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS "$BASE/healthz" >/dev/null 2>&1; then break; fi 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
sleep 1 sleep 1
done done
echo "== /healthz (expect 200) ==" echo "== /healthz (expect 200) =="
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/healthz") code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)")
[ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; } [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs "$CONTAINER"; exit 1; }
echo "== MCP without token (expect 401) ==" echo "== MCP without token (expect 401) =="
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/mcp" \ 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':{}})")
-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; } [ "$code" = "401" ] || { echo "::error::unauthenticated MCP returned $code"; exit 1; }
echo "== MCP with valid token (expect 200) ==" echo "== MCP with valid token (expect 200) =="
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/mcp" \ 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'}}})")
-H "Authorization: Bearer $TOKEN" \ [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs "$CONTAINER"; exit 1; }
-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." echo "Image smoke-test passed."
@@ -133,7 +143,7 @@ jobs:
if: always() if: always()
shell: bash shell: bash
run: | run: |
docker rm -f familiar-smoke || true docker rm -f ${{ steps.meta.outputs.container }} || true
docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true
docker image prune -f || true docker image prune -f || true
docker builder prune -f || true docker builder prune -f || true
+5 -10
View File
@@ -77,8 +77,8 @@ Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret `
```bash ```bash
cp .env.example .env cp .env.example .env
# edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32), FAMILIAR_DOMAIN, # edit .env: FAMILIAR_HTTP_TOKEN (openssl rand -hex 32),
# cert resolver, FAMILIAR_IMAGE (:latest prod / :dev dev channel) # FAMILIAR_IMAGE (:latest prod / :dev dev channel)
docker network create proxy # once, if your reverse proxy doesn't own it yet docker network create proxy # once, if your reverse proxy doesn't own it yet
@@ -121,16 +121,11 @@ familiar.yourdomain.tld {
Caddy fetches and renews the certificate automatically and streams by default. Caddy fetches and renews the certificate automatically and streams by default.
### Traefik (labels included) ### Pangolin
`docker-compose.yml` already carries Traefik router labels. Set in `.env`: 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`.
``` 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 ## Operational notes
+3 -13
View File
@@ -60,21 +60,11 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
labels: # No proxy labels: Pangolin discovers this service on the shared `proxy`
# --- Traefik TLS termination (Let's Encrypt) -------------------------- # # network and forwards to :8080 — configure the route in Pangolin, not here.
# 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: networks:
# Shared reverse-proxy network (Traefik/Caddy). Create once with: # Shared reverse-proxy network (Pangolin). Create once with:
# docker network create proxy # docker network create proxy
proxy: proxy:
external: true external: true