Compare commits

..
Author SHA1 Message Date
jschoubben 62cecc8a2b supabase: the self-hosted stack as one module, its secrets rendered into files
ace runs Supabase under HAL as upstream's 13-container compose: a 2.2 GB
database (1.8 GB of it the dormant `novox` schema, 5.8 M rows in its largest
table), Kong at supabase.zurag.be, the pooler on 5433/6543. This is that stack
as a catalogue module, same images (the digests ace runs), same container
names so an assignment holds the running ones and a take replaces them.

The database stays inside the module. Supabase is a Postgres distribution: its
own image with pgsodium, pg_graphql, pg_net, vault and timescale preloaded, a
superuser (supabase_admin), a dozen reserved roles and a second database
(_supabase). A postgres-database grant - one database, one unprivileged role -
cannot hold it, so ace's data directory moves as a copy, not a dump/restore.

What HAL did by shell and environment the mesh now renders as files:
- kong.yml carries the anon/service keys and the dashboard login (owned by
  kong's uid 100), instead of an entrypoint that eval'd the environment;
- GoTrue reads a dotenv file (auth -c), PostgREST a config file, Vector its yml
  with the Logflare key in it, the database POSTGRES_PASSWORD_FILE and a
  jwt.sql rendered with the secret (owned by postgres, uid 105). None of these
  five containers has a secret in its environment.
- realtime, storage, meta, functions, analytics, studio and supavisor read
  their credentials from the environment only; each declares
  secrets-in-environment with the reason (ADR 0086).
- Upstreams are container names (supabase-db, supabase-kong, ...) instead of
  compose service names, which the mesh does not have.
- SITE_URL / API_EXTERNAL_URL / SUPABASE_PUBLIC_URL are
  https://${bound:route:name} (mesh-controller #149); on ace HAL rendered them
  as "https://supabase." - broken today.
- Vector reads the docker socket, as upstream does, but now includes only this
  module's containers instead of every container's logs on the machine.

Three things compose did that a declaration cannot, done as steps: a run-once
seed copies the image's /etc/postgresql-custom into the placed config
directory with cp -n (a named volume did that implicitly; never overwrites the
pgsodium root key), and two run-once gates wait for the database and for
Logflare, which compose expressed as depends_on: service_healthy.

The pooler bootstrap (pooler.exs) takes the tenant id and pool sizes from
settings.json, the module's one merge:json file, so ace keeps its tenant
"zurag"; and it repoints an existing tenant whose database host is not
supabase-db - HAL created ace's with host "db", which no longer resolves.

Secrets (vault, requires "secret"): postgres, jwt, anon-key,
service-role-key, dashboard-user, dashboard, logflare, pooler-vault,
key-base-a + key-base-b (concatenated: Phoenix wants 64+ bytes, a minted
secret is 40), openai. Three cannot be minted on any machine: anon-key and
service-role-key are JWTs signed with jwt, and pooler-vault must be exactly
32 bytes (AES-256-GCM, found in the bed). They are accepted. On ace every
secret the data already knows is accepted (all but key-base-a/b).

Not carried: Kong's 8443 and Logflare's 4000 on all interfaces (nothing
outside the module uses them); realtime's DB_ENC_KEY stays upstream's constant
(realtime deletes and re-seeds that tenant from its environment every start,
and the key must be exactly 16 bytes).

Verified: catalogue tests with MESH_CATALOGUE pointed here on mesh-controller
main and #149 (on main the render is refused for "name", never written
empty). The #149 resolution with stub providers, turned into a throwaway
stack of all 13 pinned digests with dummy secrets and the rendered files at
their owners and modes: the database initialised through the rendered scripts
(jwt setting applied, _analytics/_supavisor created, roles' password from
POSTGRES_PASSWORD_FILE); through Kong: REST 200 with the anon key and 401
without, auth health and settings 200, storage buckets 200, GraphQL 200, pg-meta
200, an edge function 200, Studio 401 without and 200 with the dashboard login,
realtime tenant health 200; the pooler in session and transaction mode as
postgres.zurag; a tenant set to host "db" was repointed to supabase-db by the
bootstrap and connections worked.
2026-09-30 12:12:27 +02:00
3 changed files with 567 additions and 29 deletions
+4 -8
View File
@@ -1,10 +1,10 @@
// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools
// import it; nothing outside letta does.
//
// Letta authenticates with a single server password. That password is a mesh own-secret handed to
// both the server (LETTA_SERVER_PASSWORD) and this client, through the runtime config file the mesh
// mounts (its `password` key) — so the module's tools are live without anything configured by hand.
// Where a server already has clients, the password is accepted rather than minted.
// Letta authenticates with a single server password, presented as a Bearer token. That password is
// a mesh own-secret, minted once and handed to both the server (LETTA_SERVER_PASSWORD) and this
// client (MESH_LETTA_PASSWORD) — so the module's tools are live without anything configured by hand.
// The runtime config file may still override the URL or password.
import { readFileSync } from "node:fs";
@@ -58,10 +58,6 @@ export class LettaClient {
...options,
headers: {
"Content-Type": "application/json",
// The server's --secure mode checks X-BARE-PASSWORD ("password <it>") and answers a Bearer
// token alone with 401 (letta/server/rest_api/app.py, 0.6.x). Both are sent: Bearer is what
// later servers read.
"X-BARE-PASSWORD": `password ${this.password}`,
Authorization: `Bearer ${this.password}`,
...(options.headers as Record<string, string> | undefined),
},
+25 -21
View File
@@ -5,28 +5,21 @@
"container-runtime"
],
"requires": [
"postgres-database",
"route"
"postgres-database"
],
"contributes": {
"postgres-database": {
"name": "letta"
},
"route": {
"label": "letta",
"endpoint": "web"
}
},
"binds": {
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
"postgres-database": "/var/lib/letta/database.json"
},
"secrets": {
"postgres-database": "${dir:state}/database.secret"
"postgres-database": "/var/lib/letta/database.secret"
},
"own-secrets": {
"server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret",
"server-password": "/var/lib/letta/server-password.secret",
"broker": "/var/lib/mesh/letta/broker"
},
"listens": [
@@ -35,7 +28,7 @@
"port": 8283,
"protocol": "tcp",
"from": "mesh",
"why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's"
"why": "the Letta agent server REST API and web UI; a public name is a route grant later"
}
],
"resources": [
@@ -48,15 +41,15 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/letta",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"path": "/var/lib/letta/server.env",
"mode": "0600",
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nSECURE=true\nTZ=Europe/Brussels\n"
},
{
"id": "net",
@@ -67,24 +60,31 @@
"id": "server",
"type": "container",
"name": "letta",
"image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29",
"image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b",
"network": "letta",
"env-file": [
"${dir:state}/server.env"
"/var/lib/letta/server.env"
],
"ports": [
"8283"
],
"secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin), and its startup.sh starts an embedded PostgreSQL unless LETTA_PG_URI is set - so the database password travels inside that URI (startup.sh also echoes it to the log); LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source either"
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/letta/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime-env",
"type": "file",
"path": "/var/lib/letta/runtime.env",
"mode": "0600",
"content": "MESH_LETTA_PASSWORD=${secret:server-password}\n"
},
{
"id": "runtime",
"type": "container",
@@ -99,10 +99,14 @@
"MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
},
"env-file": [
"/var/lib/letta/runtime.env"
],
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
"artifact": "runtime",
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
}
],
"build": {
File diff suppressed because one or more lines are too long