From 01ccbb8e827d5cd5609d8f817f28054c812a97c1 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 23 Aug 2026 10:51:35 +0100 Subject: [PATCH] feat(flags): add the Unleash stack and its runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its own Portainer stack, belonging to neither application stack: a staging redeploy must not be able to disturb production's flag state. One instance serves both. OSS Unleash ships development and production environments with environment-scoped client tokens, so the same flag holds independent state in each — which is what lets a feature be on in staging, where the E2E journeys exercise it, while production stays dark. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- docker-compose.portainer.unleash.yml | 73 ++++++++++++++++++++++++++++ docs/DEPLOY.md | 51 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 docker-compose.portainer.unleash.yml diff --git a/docker-compose.portainer.unleash.yml b/docker-compose.portainer.unleash.yml new file mode 100644 index 0000000..ec20643 --- /dev/null +++ b/docker-compose.portainer.unleash.yml @@ -0,0 +1,73 @@ +# Portainer Stack Definition for School Compare — UNLEASH (feature flags) +# +# Deploy as a *separate* Portainer stack ("schoolcompare-unleash"), alongside +# the production and staging stacks. It deliberately belongs to neither: a +# staging redeploy must not be able to disturb production's flag state, and a +# production redeploy must not disturb staging's. +# +# One instance serves both environments. Open-source Unleash ships with +# `development` and `production` environments and environment-scoped client +# tokens, so the same flag holds independent state in each — which is what +# lets a feature be on in staging, where the E2E journeys exercise it, while +# production stays dark. +# +# Portainer environment variables (set in Portainer UI -> Stack -> Environment): +# UNLEASH_DB_PASSWORD — PostgreSQL password for the Unleash database +# UNLEASH_ADMIN_PASSWORD — initial admin password for the Unleash UI +# UNLEASH_IP — macvlan IP for the Unleash server (default 10.0.1.152) + +services: + + # ── PostgreSQL (Unleash's own; nothing else uses it) ────────────────── + unleash_db: + container_name: sc_unleash_postgres + image: postgres:16-alpine + environment: + POSTGRES_USER: unleash + POSTGRES_PASSWORD: ${UNLEASH_DB_PASSWORD} + POSTGRES_DB: unleash + volumes: + - unleash_postgres_data:/var/lib/postgresql/data + networks: + - unleash + healthcheck: + test: ["CMD-SHELL", "pg_isready -U unleash"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + # ── Unleash server (UI + client API on 4242) ────────────────────────── + unleash: + container_name: sc_unleash + image: unleashorg/unleash-server:6 + environment: + DATABASE_URL: postgres://unleash:${UNLEASH_DB_PASSWORD}@unleash_db:5432/unleash + DATABASE_SSL: "false" + INIT_ADMIN_API_TOKENS: "" + UNLEASH_DEFAULT_ADMIN_PASSWORD: ${UNLEASH_ADMIN_PASSWORD} + depends_on: + unleash_db: + condition: service_healthy + networks: + unleash: {} + macvlan: + ipv4_address: ${UNLEASH_IP:-10.0.1.152} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:4242/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + +networks: + unleash: + driver: bridge + macvlan: + external: + name: macvlan + +volumes: + unleash_postgres_data: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 02123f6..14b6f83 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -150,3 +150,54 @@ token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup needed), and fails the check only when a finding is rated **severe** (would break prod, leak data, or corrupt data). Minor findings are informational and never block a merge. + +## Feature flags (Unleash) + +Flag state lives in a self-hosted Unleash instance, deployed as its own +Portainer stack from `docker-compose.portainer.unleash.yml`. It is separate +from the application stacks on purpose — redeploying staging must not be able +to disturb production's flags. + +The flags themselves are declared in `backend/flags.py`. Unleash holds the +state; the registry holds the list. A flag in the UI that is not in the +registry is orphaned and nothing reads it. + +### First-time setup + +1. Deploy the stack in Portainer. Set `UNLEASH_DB_PASSWORD`, + `UNLEASH_ADMIN_PASSWORD` and (optionally) `UNLEASH_IP`. +2. Log in to the UI at `http://:4242` as `admin`. +3. Create one **client** API token per environment: + - `schoolcompare-staging`, environment **development** + - `schoolcompare-prod`, environment **production** + + Client tokens, not admin tokens — the backend only reads. +4. Put each token in the matching Portainer stack's `UNLEASH_API_TOKEN` + variable, and set `UNLEASH_URL` to `http://:4242/api`. +5. Redeploy the application stacks. + +### Turning a feature on + +Toggle the flag in the environment you want. Flags appear in the Unleash UI +after the backend has evaluated them once, so a newly declared flag shows up +shortly after the deploy that introduced it. + +A flip reaches school pages within about five minutes and place pages within +the hour. Next's ISR does the propagating — it revalidates a route at the +*lowest* `revalidate` among that route's fetches, which is 300s for +`/school/[slug]` and 3600s for the place pages. There is no webhook, and +adding one would only be worth it if flips ever needed to be instant. + +### When Unleash is unreachable + +Every flag evaluates to `False` and the site serves as though nothing were +switched on. That is deliberate — an unfinished feature staying hidden is the +safe direction — but it means a *released* feature disappears if a backend +container cold-starts with an empty cache while Unleash is down. The SDK's +disk cache is on a named volume so restarts keep last-known state, and flags +are removed from the code within 90 days (enforced by a test), which bounds +how long any feature is exposed to this. + +If `UNLEASH_URL` is unset, every flag is `False` and no connection is +attempted. That is the correct behaviour for local development and CI, and it +means the test suites need no flag server.