feat: add FRANCIS_HOST to connect to a standalone Francis runtime

FRANCIS_HOST decides where the Francis actor runtime lives. When set to "embedded" (the default), Pocket ID starts the runtime inside its own process.

Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime.

Because when using a remote runtime, it's likewise not possible to enforce a single instance of Pocket ID is running at once, the env vars currently have the `EXPERIMENTAL_` prefix, are **undocumented**, and show a warning if used.

Notes:

- Connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE, and optionally (but recommended) FRANCIS_CA.
- When connecting to a remote runtime, exporting Pocket ID data does not include the actor state, which will need to be backed up and restored separately
This commit is contained in:
ItalyPaleAle
2026-09-19 23:38:18 -07:00
parent 4ba28992ff
commit fadb1a5552
35 changed files with 1125 additions and 130 deletions
+50
View File
@@ -0,0 +1,50 @@
# This Docker Compose file is used to set up the environment for the tests.
# It's the variant where Pocket ID does not embed the Francis actor runtime, but connects to a standalone one instead.
services:
lldap:
extends:
file: docker-compose.yml
service: lldap
scim-test-server:
extends:
file: docker-compose.yml
service: scim-test-server
francis-runtime:
# The version comes from the Francis dependency in backend/go.mod, so the runtime always matches the client Pocket ID is built with
# Set it with: echo "FRANCIS_VERSION=$(./francis-version.sh)" > .env
image: ghcr.io/italypaleale/francis:${FRANCIS_VERSION:?run ./francis-version.sh to set it, see CONTRIBUTING.md}
volumes:
- ./francis-config.yaml:/etc/francis/config.yaml:ro
- francis-test-data:/data
# The image ships its own HEALTHCHECK, which probes the runtime over the loopback
# It's repeated here so Pocket ID can wait on it, and so a runtime that never comes up fails fast instead of after the default retries
healthcheck:
test: ["CMD", "/bin/francis", "healthcheck"]
interval: 2s
timeout: 5s
retries: 15
start_period: 5s
pocket-id:
extends:
file: docker-compose.yml
service: pocket-id
environment:
APP_ENV: test
ENCRYPTION_KEY: test-encryption-key
FILE_BACKEND: ${FILE_BACKEND}
# The runtime's port is UDP, since WebTransport runs over HTTP/3
FRANCIS_HOST: francis-runtime:7400
# Must match "bootstrap.hostPSK" in francis-config.yaml
FRANCIS_HOST_PSK: e2e-host-bootstrap-psk-0123456789
# FRANCIS_CA is intentionally unset, so this exercises the same trust-on-first-use path an operator gets without it
# The cluster only exists inside this Compose network for the duration of the tests
#
# Peers reach actors placed on this host at ACTORS_HOST, which the runtime hands out, so it has to be the address other containers resolve rather than the default wildcard
ACTORS_HOST: pocket-id
depends_on:
francis-runtime:
condition: service_healthy
volumes:
pocket-id-test-data:
francis-test-data:
+26
View File
@@ -0,0 +1,26 @@
# Configuration for the standalone Francis runtime used by the "remote Francis" E2E variant.
# In that variant Pocket ID does not embed the actor runtime: it connects to this one instead, which owns the actor state, placement, and alarms.
# These secrets are fixed test values and must match the FRANCIS_HOST_PSK passed to Pocket ID in docker-compose-francis.yml.
# The WebTransport server runs over HTTP/3, so this port is UDP
bind: "0.0.0.0:7400"
# The runtime PSKs derive the cluster CA that signs every workload certificate
runtimePSKs:
- "e2e-runtime-psk-0123456789abcdef"
# Hosts prove they may join by presenting this pre-shared key
bootstrap:
method: psk
hostPSK: "e2e-host-bootstrap-psk-0123456789"
# The runtime owns its own SQLite store, which is separate from Pocket ID's database
# It lives on a volume because the image runs as a non-root user that cannot write to the image filesystem
provider:
connectionString: "/data/francis.db"
# A single Pocket ID replica joins the cluster, matching the cap the embedded runtime applies when HA is off
maxHosts: 1
log:
level: debug
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Prints the Francis version Pocket ID depends on, formatted as the tag of the runtime's container image.
#
# The Go module is the single source of truth for the version: the standalone runtime the E2E tests run against
# has to be the same version as the client Pocket ID is built with, and pinning it in two places lets them drift.
#
# Usage, from this directory:
# echo "FRANCIS_VERSION=$(./francis-version.sh)" > .env
#
# This is a plain shell script rather than "go list" because the E2E workflow does not set up Go.
set -eu
go_mod="$(CDPATH='' cd -- "$(dirname -- "$0")/../../backend" && pwd)/go.mod"
if [ ! -f "$go_mod" ]; then
echo "francis-version.sh: cannot find $go_mod" >&2
exit 1
fi
# Module versions carry a leading "v" that container tags do not, so it is stripped
version=$(sed -n 's|^[[:space:]]*github\.com/italypaleale/francis v\([^[:space:]]*\).*|\1|p' "$go_mod" | head -n 1)
if [ -z "$version" ]; then
echo "francis-version.sh: no github.com/italypaleale/francis requirement found in $go_mod" >&2
exit 1
fi
printf '%s\n' "$version"