From 6d459e7d4c845e5ffb827c0f8dd6a5fd311a130e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 18 Jul 2026 21:46:50 -0400 Subject: [PATCH 1/2] feat: publish standalone compose deployments --- .changeset/standalone-compose-deployments.md | 5 + .github/workflows/ci.yml | 15 + .github/workflows/release.yml | 53 +-- Dockerfile | 2 +- deploy/postgres/init-caplets-roles.sh | 52 --- deploy/postgres/postgres-environment.mjs | 34 ++ deploy/postgres/provision-roles.mjs | 177 ++++++++ deploy/postgres/render-config.mjs | 43 +- docker-compose.postgres-hardened.yml | 180 +++++++++ docker-compose.postgres.yml | 57 ++- docker-compose.yml | 7 +- ...ingle-role-postgres-convenience-compose.md | 3 + docs/architecture.md | 2 +- .../sql-authoritative-host-state.md | 176 ++++++-- ...26-07-18-standalone-compose-deployments.md | 235 +++++++++++ package.json | 6 +- .../test/postgres-deployment-config.test.ts | 73 ++++ scripts/smoke-compose-deployments.mjs | 377 ++++++++++++++++++ scripts/sync-compose-image-version.mjs | 25 ++ 19 files changed, 1360 insertions(+), 162 deletions(-) create mode 100644 .changeset/standalone-compose-deployments.md delete mode 100755 deploy/postgres/init-caplets-roles.sh create mode 100644 deploy/postgres/postgres-environment.mjs create mode 100755 deploy/postgres/provision-roles.mjs create mode 100644 docker-compose.postgres-hardened.yml create mode 100644 docs/adr/0006-single-role-postgres-convenience-compose.md create mode 100644 docs/specs/2026-07-18-standalone-compose-deployments.md create mode 100644 packages/core/test/postgres-deployment-config.test.ts create mode 100755 scripts/smoke-compose-deployments.mjs create mode 100755 scripts/sync-compose-image-version.mjs diff --git a/.changeset/standalone-compose-deployments.md b/.changeset/standalone-compose-deployments.md new file mode 100644 index 00000000..981a5991 --- /dev/null +++ b/.changeset/standalone-compose-deployments.md @@ -0,0 +1,5 @@ +--- +"caplets": patch +--- + +Publish checkout-free SQLite, convenience PostgreSQL, and hardened PostgreSQL Docker Compose deployments with image-packaged migration helpers. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40f15e46..3f89d1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,21 @@ jobs: - name: Run quality gates run: pnpm verify + compose: + name: Compose smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Run standalone deployment smoke tests + run: node scripts/smoke-compose-deployments.mjs + changeset: name: Changeset runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7af98e3..c926ea08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,37 +69,33 @@ jobs: CAPLETS_SENTRY_ENVIRONMENT: production GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Check whether CLI package was published - if: steps.changesets.outputs.published == 'true' - id: cli-package + - name: Detect CLI release for this commit + id: cli-release env: - PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} - run: | - cli_published=$(node <<'NODE' - const publishedPackages = JSON.parse(process.env.PUBLISHED_PACKAGES || '[]'); - const cliPublished = publishedPackages.some((pkg) => pkg && pkg.name === 'caplets'); - process.stdout.write(cliPublished ? 'true' : 'false'); - NODE - ) - echo "published=${cli_published}" >> "$GITHUB_OUTPUT" - - - name: Read Docker image version - if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true' - id: image-version + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=$(node -p "require('./packages/cli/package.json').version") + tag="caplets@${version}" + git fetch --force --tags origin + tag_commit=$(git rev-list -n 1 "$tag" 2>/dev/null || true) + published=false + if [ "$tag_commit" = "$GITHUB_SHA" ]; then + gh release view "$tag" >/dev/null + published=true + fi + echo "published=${published}" >> "$GITHUB_OUTPUT" echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Setup QEMU - if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true' + if: steps.cli-release.outputs.published == 'true' uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 - name: Setup Docker Buildx - if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true' + if: steps.cli-release.outputs.published == 'true' uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Log in to GitHub Container Registry - if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true' + if: steps.cli-release.outputs.published == 'true' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee with: registry: ghcr.io @@ -107,19 +103,19 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Generate Docker metadata - if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true' + if: steps.cli-release.outputs.published == 'true' id: docker-meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 with: images: ghcr.io/spiritledsoftware/caplets tags: | type=raw,value=latest - type=raw,value=${{ steps.image-version.outputs.version }} - type=raw,value=v${{ steps.image-version.outputs.version }} + type=raw,value=${{ steps.cli-release.outputs.version }} + type=raw,value=v${{ steps.cli-release.outputs.version }} type=sha,format=short,prefix=sha- - name: Publish Docker image - if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true' + if: steps.cli-release.outputs.published == 'true' uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf with: context: . @@ -133,3 +129,14 @@ jobs: CAPLETS_RUNTIME_SENTRY_DSN=${{ secrets.CAPLETS_RUNTIME_SENTRY_DSN }} CAPLETS_SENTRY_RELEASE=caplets-runtime@${{ github.sha }} CAPLETS_SENTRY_ENVIRONMENT=production + + - name: Publish Docker Compose release assets + if: steps.cli-release.outputs.published == 'true' + run: >- + gh release upload "caplets@${{ steps.cli-release.outputs.version }}" + docker-compose.yml + docker-compose.postgres.yml + docker-compose.postgres-hardened.yml + --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 0260ff5d..2895ccc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,8 +46,8 @@ RUN mkdir -p /data/config /data/state && \ chown -R node:root /app /data COPY --from=build --chown=node:root /deploy ./ +COPY --from=build --chown=node:root /app/deploy/postgres/*.mjs /usr/local/lib/caplets/postgres/ -VOLUME ["/data"] EXPOSE 5387 USER node diff --git a/deploy/postgres/init-caplets-roles.sh b/deploy/postgres/init-caplets-roles.sh deleted file mode 100755 index e9363ac7..00000000 --- a/deploy/postgres/init-caplets-roles.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -set -eu - -: "${POSTGRES_DB:?POSTGRES_DB is required}" -: "${POSTGRES_USER:?POSTGRES_USER is required}" -: "${CAPLETS_POSTGRES_MIGRATOR_PASSWORD:?CAPLETS_POSTGRES_MIGRATOR_PASSWORD is required}" -: "${CAPLETS_POSTGRES_RUNTIME_PASSWORD:?CAPLETS_POSTGRES_RUNTIME_PASSWORD is required}" - -schema=${CAPLETS_POSTGRES_SCHEMA:-caplets} -case "$schema" in - [a-z_]* ) ;; - * ) echo "CAPLETS_POSTGRES_SCHEMA must start with a lowercase letter or underscore" >&2; exit 1 ;; -esac -case "$schema" in - *[!a-z0-9_]* ) echo "CAPLETS_POSTGRES_SCHEMA contains an invalid character" >&2; exit 1 ;; -esac -if [ "${#schema}" -gt 63 ]; then - echo "CAPLETS_POSTGRES_SCHEMA must not exceed 63 characters" >&2 - exit 1 -fi - -psql --set=ON_ERROR_STOP=1 \ - --username "$POSTGRES_USER" \ - --dbname "$POSTGRES_DB" \ - --set=database="$POSTGRES_DB" \ - --set=schema="$schema" \ - --set=migrator_password="$CAPLETS_POSTGRES_MIGRATOR_PASSWORD" \ - --set=runtime_password="$CAPLETS_POSTGRES_RUNTIME_PASSWORD" <<'SQL' -REVOKE ALL ON DATABASE :"database" FROM PUBLIC; - -CREATE ROLE caplets_migrator LOGIN NOINHERIT PASSWORD :'migrator_password'; -CREATE ROLE caplets_runtime LOGIN NOINHERIT PASSWORD :'runtime_password'; - -GRANT CONNECT, CREATE ON DATABASE :"database" TO caplets_migrator; -GRANT CONNECT ON DATABASE :"database" TO caplets_runtime; - -CREATE SCHEMA :"schema" AUTHORIZATION caplets_migrator; -REVOKE ALL ON SCHEMA :"schema" FROM PUBLIC; -GRANT USAGE ON SCHEMA :"schema" TO caplets_runtime; - -ALTER ROLE caplets_migrator IN DATABASE :"database" SET search_path TO :"schema"; -ALTER ROLE caplets_runtime IN DATABASE :"database" SET search_path TO :"schema"; - -ALTER DEFAULT PRIVILEGES FOR ROLE caplets_migrator IN SCHEMA :"schema" - REVOKE ALL ON TABLES FROM PUBLIC; -ALTER DEFAULT PRIVILEGES FOR ROLE caplets_migrator IN SCHEMA :"schema" - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO caplets_runtime; -ALTER DEFAULT PRIVILEGES FOR ROLE caplets_migrator IN SCHEMA :"schema" - REVOKE ALL ON SEQUENCES FROM PUBLIC; -ALTER DEFAULT PRIVILEGES FOR ROLE caplets_migrator IN SCHEMA :"schema" - GRANT USAGE, SELECT ON SEQUENCES TO caplets_runtime; -SQL diff --git a/deploy/postgres/postgres-environment.mjs b/deploy/postgres/postgres-environment.mjs new file mode 100644 index 00000000..8e421015 --- /dev/null +++ b/deploy/postgres/postgres-environment.mjs @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; + +export function readCredential(name) { + const direct = process.env[name]; + const file = process.env[`${name}_FILE`]; + if (direct && file) throw new Error(`set ${name} or ${name}_FILE, not both`); + if (direct) return direct; + if (!file) throw new Error(`${name} or ${name}_FILE is required`); + const value = readFileSync(file, "utf8").replace(/\r?\n$/u, ""); + if (!value) throw new Error(`${name}_FILE must not be empty`); + return value; +} + +export function postgresSchema() { + const schema = process.env.CAPLETS_POSTGRES_SCHEMA || "caplets"; + if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(schema)) { + throw new Error("CAPLETS_POSTGRES_SCHEMA must match ^[a-z_][a-z0-9_]{0,62}$"); + } + return schema; +} + +export function postgresConnectionString(user, password) { + const connection = new URL("postgresql://localhost"); + connection.username = user; + connection.password = password; + connection.hostname = process.env.CAPLETS_POSTGRES_HOST || "caplets-postgres"; + connection.port = process.env.CAPLETS_POSTGRES_PORT || "5432"; + connection.pathname = `/${process.env.CAPLETS_POSTGRES_DATABASE || "caplets"}`; + return connection.toString(); +} + +export function quoteIdentifier(identifier) { + return `"${identifier.replaceAll('"', '""')}"`; +} diff --git a/deploy/postgres/provision-roles.mjs b/deploy/postgres/provision-roles.mjs new file mode 100755 index 00000000..dbc4aff7 --- /dev/null +++ b/deploy/postgres/provision-roles.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import { createRequire } from "node:module"; +import { randomBytes } from "node:crypto"; +import { + postgresConnectionString, + postgresSchema, + quoteIdentifier, + readCredential, +} from "./postgres-environment.mjs"; + +const require = createRequire("/app/dist/index.js"); +const { Pool } = require("pg"); +const mode = process.argv[2] || "hardened"; +if (mode !== "convenience" && mode !== "hardened") { + throw new Error("usage: provision-roles.mjs [convenience|hardened]"); +} + +const database = process.env.CAPLETS_POSTGRES_DATABASE || "caplets"; +const schema = postgresSchema(); +const connection = + mode === "convenience" + ? await connectConvenience() + : await connect( + process.env.CAPLETS_POSTGRES_ADMIN_USER || "caplets_admin", + readCredential("CAPLETS_POSTGRES_ADMIN_PASSWORD"), + ); +const { client, pool } = connection; + +try { + await client.query("BEGIN"); + if (mode === "convenience") { + await provisionConvenienceRole(connection.bootstrapRole); + } else { + await provisionHardenedRoles(); + } + await client.query("COMMIT"); +} catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; +} finally { + client.release(); + await pool.end(); +} + +async function provisionConvenienceRole(bootstrapRole) { + const role = "caplets"; + const password = readCredential("CAPLETS_POSTGRES_PASSWORD"); + if (bootstrapRole) { + await reconcileRole(role, password); + } else { + await reconcileConvenienceRole(role, password); + } + + const quotedDatabase = quoteIdentifier(database); + const quotedSchema = quoteIdentifier(schema); + await client.query(`REVOKE ALL ON DATABASE ${quotedDatabase} FROM PUBLIC`); + await client.query(`ALTER DATABASE ${quotedDatabase} OWNER TO ${role}`); + await client.query("REVOKE CREATE ON SCHEMA public FROM PUBLIC"); + await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema} AUTHORIZATION ${role}`); + await client.query(`ALTER SCHEMA ${quotedSchema} OWNER TO ${role}`); + await client.query(`REVOKE ALL ON SCHEMA ${quotedSchema} FROM PUBLIC`); + await client.query( + `ALTER ROLE ${role} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, + ); + if (bootstrapRole) await rotateBootstrapPassword(bootstrapRole); +} + +async function reconcileConvenienceRole(role, password) { + const result = await client.query( + "SELECT rolcanlogin, rolinherit, rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls FROM pg_roles WHERE rolname = $1", + [role], + ); + if (result.rowCount !== 1) throw new Error(`PostgreSQL role ${role} does not exist`); + + const current = result.rows[0]; + if ( + !current.rolcanlogin || + current.rolinherit || + current.rolsuper || + current.rolcreatedb || + current.rolcreaterole || + current.rolreplication || + current.rolbypassrls + ) { + throw new Error(`PostgreSQL role ${role} has unexpected attributes`); + } + + const statement = await client.query( + "SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text) AS sql", + [role, password], + ); + await client.query(statement.rows[0].sql); +} + +async function rotateBootstrapPassword(role) { + const statement = await client.query( + "SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text) AS sql", + [role, randomBytes(48).toString("base64url")], + ); + await client.query(statement.rows[0].sql); +} + +async function provisionHardenedRoles() { + const migratorRole = "caplets_migrator"; + const runtimeRole = "caplets_runtime"; + const migratorPassword = readCredential("CAPLETS_POSTGRES_MIGRATOR_PASSWORD"); + const runtimePassword = readCredential("CAPLETS_POSTGRES_RUNTIME_PASSWORD"); + await reconcileRole(migratorRole, migratorPassword); + await reconcileRole(runtimeRole, runtimePassword); + + const quotedDatabase = quoteIdentifier(database); + const quotedSchema = quoteIdentifier(schema); + await client.query(`REVOKE ALL ON DATABASE ${quotedDatabase} FROM PUBLIC`); + await client.query(`GRANT CONNECT, CREATE ON DATABASE ${quotedDatabase} TO ${migratorRole}`); + await client.query(`GRANT CONNECT ON DATABASE ${quotedDatabase} TO ${runtimeRole}`); + await client.query("REVOKE CREATE ON SCHEMA public FROM PUBLIC"); + await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema} AUTHORIZATION ${migratorRole}`); + await client.query(`ALTER SCHEMA ${quotedSchema} OWNER TO ${migratorRole}`); + await client.query(`REVOKE ALL ON SCHEMA ${quotedSchema} FROM PUBLIC`); + await client.query(`GRANT USAGE ON SCHEMA ${quotedSchema} TO ${runtimeRole}`); + await client.query( + `ALTER ROLE ${migratorRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, + ); + await client.query( + `ALTER ROLE ${runtimeRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, + ); + await client.query( + `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} REVOKE ALL ON TABLES FROM PUBLIC`, + ); + await client.query( + `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${runtimeRole}`, + ); + await client.query( + `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} REVOKE ALL ON SEQUENCES FROM PUBLIC`, + ); + await client.query( + `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} GRANT USAGE, SELECT ON SEQUENCES TO ${runtimeRole}`, + ); +} + +async function reconcileRole(role, password) { + const existing = await client.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [role]); + if (existing.rowCount === 0) await client.query(`CREATE ROLE ${role}`); + const statement = await client.query( + "SELECT format('ALTER ROLE %I LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD %L', $1::text, $2::text) AS sql", + [role, password], + ); + await client.query(statement.rows[0].sql); +} + +async function connectConvenience() { + const password = readCredential("CAPLETS_POSTGRES_PASSWORD"); + let applicationError; + try { + return { ...(await connect("caplets", password)), bootstrapRole: undefined }; + } catch (error) { + applicationError = error; + } + + const bootstrapRole = process.env.CAPLETS_POSTGRES_ADMIN_USER || "postgres"; + try { + return { ...(await connect(bootstrapRole, password)), bootstrapRole }; + } catch { + throw applicationError; + } +} + +async function connect(role, password) { + const pool = new Pool({ connectionString: postgresConnectionString(role, password) }); + try { + return { client: await pool.connect(), pool }; + } catch (error) { + await pool.end().catch(() => undefined); + throw error; + } +} diff --git a/deploy/postgres/render-config.mjs b/deploy/postgres/render-config.mjs index a653d083..bc65f69e 100755 --- a/deploy/postgres/render-config.mjs +++ b/deploy/postgres/render-config.mjs @@ -1,45 +1,38 @@ #!/usr/bin/env node import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + postgresConnectionString, + postgresSchema, + readCredential, +} from "./postgres-environment.mjs"; const mode = process.argv[2]; if (mode !== "migrator" && mode !== "runtime") { throw new Error("usage: render-config.mjs "); } -const required = (name) => { - const value = process.env[name]; - if (!value) throw new Error(`${name} is required`); - return value; -}; - -const schema = process.env.CAPLETS_POSTGRES_SCHEMA || "caplets"; -if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(schema)) { - throw new Error("CAPLETS_POSTGRES_SCHEMA must match ^[a-z_][a-z0-9_]{0,62}$"); -} - -const role = mode === "migrator" ? "caplets_migrator" : "caplets_runtime"; -const password = required( - mode === "migrator" ? "CAPLETS_POSTGRES_MIGRATOR_PASSWORD" : "CAPLETS_POSTGRES_RUNTIME_PASSWORD", -); -const host = process.env.CAPLETS_POSTGRES_HOST || "caplets-postgres"; -const port = process.env.CAPLETS_POSTGRES_PORT || "5432"; -const database = process.env.CAPLETS_POSTGRES_DATABASE || "caplets"; +const role = + process.env.CAPLETS_POSTGRES_USER || + (mode === "migrator" ? "caplets_migrator" : "caplets_runtime"); +const passwordName = + process.env.CAPLETS_POSTGRES_PASSWORD || process.env.CAPLETS_POSTGRES_PASSWORD_FILE + ? "CAPLETS_POSTGRES_PASSWORD" + : mode === "migrator" + ? "CAPLETS_POSTGRES_MIGRATOR_PASSWORD" + : "CAPLETS_POSTGRES_RUNTIME_PASSWORD"; +const password = readCredential(passwordName); +const schema = postgresSchema(); const target = process.env.CAPLETS_CONFIG || "/tmp/caplets-config.json"; const basePath = process.env.CAPLETS_BASE_CONFIG; const config = basePath && existsSync(basePath) ? JSON.parse(readFileSync(basePath, "utf8")) : { version: 1 }; const previousStorage = config.storage && typeof config.storage === "object" ? config.storage : {}; -const connection = new URL("postgresql://localhost"); -connection.username = role; -connection.password = password; -connection.hostname = host; -connection.port = port; -connection.pathname = `/${database}`; +const connectionString = postgresConnectionString(role, password); config.storage = { type: "postgres", - connectionString: connection.toString(), + connectionString, schema, ...(previousStorage.assets ? { assets: previousStorage.assets } : {}), ...(previousStorage.bundleLimits ? { bundleLimits: previousStorage.bundleLimits } : {}), diff --git a/docker-compose.postgres-hardened.yml b/docker-compose.postgres-hardened.yml new file mode 100644 index 00000000..463108c4 --- /dev/null +++ b/docker-compose.postgres-hardened.yml @@ -0,0 +1,180 @@ +# Hardened single-Docker-host reference. It is not a high-availability topology. +# Back up and test restore before changing either exact image version. +x-logging: &bounded-logging + driver: local + options: + max-size: 10m + max-file: "3" + +services: + caplets-postgres: + image: ${CAPLETS_POSTGRES_IMAGE:-postgres:17.6-bookworm} + restart: unless-stopped + stop_grace_period: 60s + environment: + POSTGRES_DB: ${CAPLETS_POSTGRES_DATABASE:-caplets} + POSTGRES_USER: caplets_admin + POSTGRES_PASSWORD_FILE: /run/secrets/caplets_postgres_admin_password + secrets: + - caplets_postgres_admin_password + volumes: + - caplets-postgres-data:/var/lib/postgresql/data + networks: + - database + healthcheck: + test: + - CMD-SHELL + - pg_isready --username caplets_admin --dbname "$${POSTGRES_DB}" + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + logging: *bounded-logging + + caplets-postgres-secrets: + image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:0.26.0} + user: root + restart: "no" + read_only: true + network_mode: none + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + security_opt: + - no-new-privileges:true + secrets: + - caplets_postgres_admin_password + - caplets_postgres_migrator_password + - caplets_postgres_runtime_password + volumes: + - caplets-postgres-admin-secret:/prepared/admin + - caplets-postgres-migrator-secret:/prepared/migrator + - caplets-postgres-runtime-secret:/prepared/runtime + entrypoint: + - /bin/sh + - -ec + command: + - | + install --owner=1000 --group=1000 --mode=0400 /run/secrets/caplets_postgres_admin_password /prepared/admin/password + install --owner=1000 --group=1000 --mode=0400 /run/secrets/caplets_postgres_migrator_password /prepared/migrator/password + install --owner=1000 --group=1000 --mode=0400 /run/secrets/caplets_postgres_runtime_password /prepared/runtime/password + logging: *bounded-logging + + caplets-postgres-migrate: + image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:0.26.0} + user: node + restart: "no" + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + depends_on: + caplets-postgres: + condition: service_healthy + caplets-postgres-secrets: + condition: service_completed_successfully + environment: + CAPLETS_CONFIG: /tmp/caplets-migrator-config.json + CAPLETS_POSTGRES_HOST: caplets-postgres + CAPLETS_POSTGRES_PORT: "5432" + CAPLETS_POSTGRES_DATABASE: ${CAPLETS_POSTGRES_DATABASE:-caplets} + CAPLETS_POSTGRES_SCHEMA: ${CAPLETS_POSTGRES_SCHEMA:-caplets} + CAPLETS_POSTGRES_ADMIN_PASSWORD_FILE: /run/caplets-secrets/admin/password + CAPLETS_POSTGRES_MIGRATOR_PASSWORD_FILE: /run/caplets-secrets/migrator/password + CAPLETS_POSTGRES_RUNTIME_PASSWORD_FILE: /run/caplets-secrets/runtime/password + volumes: + - caplets-postgres-admin-secret:/run/caplets-secrets/admin:ro + - caplets-postgres-migrator-secret:/run/caplets-secrets/migrator:ro + - caplets-postgres-runtime-secret:/run/caplets-secrets/runtime:ro + networks: + - database + entrypoint: + - /bin/sh + - -ec + command: + - | + node /usr/local/lib/caplets/postgres/provision-roles.mjs + node /usr/local/lib/caplets/postgres/render-config.mjs migrator + node dist/index.js storage schema-migrate + node /usr/local/lib/caplets/postgres/finalize-runtime-grants.mjs + logging: *bounded-logging + + caplets: + image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:0.26.0} + user: node + restart: unless-stopped + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + depends_on: + caplets-postgres: + condition: service_healthy + caplets-postgres-migrate: + condition: service_completed_successfully + environment: + CAPLETS_CONFIG: /tmp/caplets-runtime-config.json + CAPLETS_BASE_CONFIG: /data/config/caplets/config.json + CAPLETS_SERVER_URL: ${CAPLETS_SERVER_URL:-http://127.0.0.1:5387} + CAPLETS_REMOTE_SERVER_STATE_DIR: /data/state/caplets/remote-server + CAPLETS_POSTGRES_HOST: caplets-postgres + CAPLETS_POSTGRES_PORT: "5432" + CAPLETS_POSTGRES_DATABASE: ${CAPLETS_POSTGRES_DATABASE:-caplets} + CAPLETS_POSTGRES_SCHEMA: ${CAPLETS_POSTGRES_SCHEMA:-caplets} + CAPLETS_POSTGRES_RUNTIME_PASSWORD_FILE: /run/caplets-secrets/runtime/password + XDG_CONFIG_HOME: /data/config + XDG_STATE_HOME: /data/state + volumes: + - caplets-data:/data + - caplets-postgres-runtime-secret:/run/caplets-secrets/runtime:ro + networks: + - database + - runtime + ports: + - "${CAPLETS_BIND_ADDRESS:-127.0.0.1}:${CAPLETS_PORT:-5387}:5387" + command: + - /bin/sh + - -ec + - | + test -f "$${CAPLETS_BASE_CONFIG}" || env CAPLETS_CONFIG="$${CAPLETS_BASE_CONFIG}" CAPLETS_MODE=local node dist/index.js init --global + node /usr/local/lib/caplets/postgres/render-config.mjs runtime + exec env CAPLETS_MODE=local node dist/index.js serve --transport http --host 0.0.0.0 + healthcheck: + test: + - CMD-SHELL + - >- + node -e "fetch('http://127.0.0.1:5387/v1/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + logging: *bounded-logging + +volumes: + caplets-data: + caplets-postgres-data: + caplets-postgres-admin-secret: + caplets-postgres-migrator-secret: + caplets-postgres-runtime-secret: + +secrets: + caplets_postgres_admin_password: + file: ${CAPLETS_POSTGRES_ADMIN_PASSWORD_FILE:-./secrets/postgres-admin-password} + caplets_postgres_migrator_password: + file: ${CAPLETS_POSTGRES_MIGRATOR_PASSWORD_FILE:-./secrets/postgres-migrator-password} + caplets_postgres_runtime_password: + file: ${CAPLETS_POSTGRES_RUNTIME_PASSWORD_FILE:-./secrets/postgres-runtime-password} + +networks: + database: + internal: true + runtime: diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index b7ac9147..8eaf8657 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -1,31 +1,26 @@ +# Convenience topology for fresh deployments. The caplets role owns the database and schema. +# Use docker-compose.postgres-hardened.yml for separate migration and runtime roles. services: caplets-postgres: - image: postgres:17.6-bookworm + image: ${CAPLETS_POSTGRES_IMAGE:-postgres:17-bookworm} restart: unless-stopped environment: POSTGRES_DB: ${CAPLETS_POSTGRES_DATABASE:-caplets} - POSTGRES_USER: caplets_admin - POSTGRES_PASSWORD: ${CAPLETS_POSTGRES_ADMIN_PASSWORD:?set CAPLETS_POSTGRES_ADMIN_PASSWORD} - CAPLETS_POSTGRES_SCHEMA: ${CAPLETS_POSTGRES_SCHEMA:-caplets} - CAPLETS_POSTGRES_MIGRATOR_PASSWORD: ${CAPLETS_POSTGRES_MIGRATOR_PASSWORD:?set CAPLETS_POSTGRES_MIGRATOR_PASSWORD} - CAPLETS_POSTGRES_RUNTIME_PASSWORD: ${CAPLETS_POSTGRES_RUNTIME_PASSWORD:?set CAPLETS_POSTGRES_RUNTIME_PASSWORD} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${CAPLETS_POSTGRES_PASSWORD:?set CAPLETS_POSTGRES_PASSWORD} volumes: - caplets-postgres-data:/var/lib/postgresql/data - - ./deploy/postgres/init-caplets-roles.sh:/docker-entrypoint-initdb.d/10-caplets-roles.sh:ro healthcheck: test: - CMD-SHELL - - pg_isready --username caplets_admin --dbname "$${POSTGRES_DB}" + - pg_isready --username postgres --dbname "$${POSTGRES_DB}" interval: 5s timeout: 5s retries: 12 start_period: 10s caplets-postgres-migrate: - build: - context: . - dockerfile: Dockerfile - image: caplets:local + image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:latest} restart: "no" depends_on: caplets-postgres: @@ -36,20 +31,20 @@ services: CAPLETS_POSTGRES_PORT: "5432" CAPLETS_POSTGRES_DATABASE: ${CAPLETS_POSTGRES_DATABASE:-caplets} CAPLETS_POSTGRES_SCHEMA: ${CAPLETS_POSTGRES_SCHEMA:-caplets} - CAPLETS_POSTGRES_MIGRATOR_PASSWORD: ${CAPLETS_POSTGRES_MIGRATOR_PASSWORD:?set CAPLETS_POSTGRES_MIGRATOR_PASSWORD} - volumes: - - ./deploy/postgres/render-config.mjs:/usr/local/bin/render-caplets-postgres-config.mjs:ro - - ./deploy/postgres/finalize-runtime-grants.mjs:/usr/local/bin/finalize-caplets-postgres-grants.mjs:ro + CAPLETS_POSTGRES_USER: caplets + CAPLETS_POSTGRES_PASSWORD: ${CAPLETS_POSTGRES_PASSWORD:?set CAPLETS_POSTGRES_PASSWORD} entrypoint: - /bin/sh - -ec command: - | - node /usr/local/bin/render-caplets-postgres-config.mjs migrator + node /usr/local/lib/caplets/postgres/provision-roles.mjs convenience + node /usr/local/lib/caplets/postgres/render-config.mjs migrator node dist/index.js storage schema-migrate - node /usr/local/bin/finalize-caplets-postgres-grants.mjs caplets: + image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:latest} + restart: unless-stopped depends_on: caplets-postgres: condition: service_healthy @@ -58,20 +53,40 @@ services: environment: CAPLETS_CONFIG: /tmp/caplets-runtime-config.json CAPLETS_BASE_CONFIG: /data/config/caplets/config.json + CAPLETS_SERVER_URL: ${CAPLETS_SERVER_URL:-http://127.0.0.1:5387} + CAPLETS_REMOTE_SERVER_STATE_DIR: /data/state/caplets/remote-server CAPLETS_POSTGRES_HOST: caplets-postgres CAPLETS_POSTGRES_PORT: "5432" CAPLETS_POSTGRES_DATABASE: ${CAPLETS_POSTGRES_DATABASE:-caplets} CAPLETS_POSTGRES_SCHEMA: ${CAPLETS_POSTGRES_SCHEMA:-caplets} - CAPLETS_POSTGRES_RUNTIME_PASSWORD: ${CAPLETS_POSTGRES_RUNTIME_PASSWORD:?set CAPLETS_POSTGRES_RUNTIME_PASSWORD} + CAPLETS_POSTGRES_USER: caplets + CAPLETS_POSTGRES_PASSWORD: ${CAPLETS_POSTGRES_PASSWORD:?set CAPLETS_POSTGRES_PASSWORD} + XDG_CONFIG_HOME: /data/config + XDG_STATE_HOME: /data/state + env_file: + - path: .env + required: false + ports: + - "${CAPLETS_BIND_ADDRESS:-127.0.0.1}:${CAPLETS_PORT:-5387}:5387" volumes: - - ./deploy/postgres/render-config.mjs:/usr/local/bin/render-caplets-postgres-config.mjs:ro + - caplets-data:/data command: - /bin/sh - -ec - | test -f "$${CAPLETS_BASE_CONFIG}" || env CAPLETS_CONFIG="$${CAPLETS_BASE_CONFIG}" CAPLETS_MODE=local node dist/index.js init --global - node /usr/local/bin/render-caplets-postgres-config.mjs runtime + node /usr/local/lib/caplets/postgres/render-config.mjs runtime exec env CAPLETS_MODE=local node dist/index.js serve --transport http --host 0.0.0.0 + healthcheck: + test: + - CMD-SHELL + - >- + node -e "fetch('http://127.0.0.1:5387/v1/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s volumes: + caplets-data: caplets-postgres-data: diff --git a/docker-compose.yml b/docker-compose.yml index ea861a6a..546b9ba3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,10 @@ services: caplets: - build: - context: . - dockerfile: Dockerfile - image: caplets:local + image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:latest} restart: unless-stopped environment: CAPLETS_SERVER_URL: ${CAPLETS_SERVER_URL:-http://127.0.0.1:5387} CAPLETS_REMOTE_SERVER_STATE_DIR: /data/state/caplets/remote-server - CAPLETS_SENTRY_RELEASE: ${CAPLETS_SENTRY_RELEASE:-caplets-docker-local} - CAPLETS_SENTRY_ENVIRONMENT: ${CAPLETS_SENTRY_ENVIRONMENT:-development} XDG_CONFIG_HOME: /data/config XDG_STATE_HOME: /data/state env_file: diff --git a/docs/adr/0006-single-role-postgres-convenience-compose.md b/docs/adr/0006-single-role-postgres-convenience-compose.md new file mode 100644 index 00000000..fa9e3c56 --- /dev/null +++ b/docs/adr/0006-single-role-postgres-convenience-compose.md @@ -0,0 +1,3 @@ +# Use One PostgreSQL Owner Role In Convenience Compose + +The standalone `docker-compose.postgres.yml` deployment uses one `caplets` owner role for database initialization, schema migration, and runtime access so a fresh installation requires only one operator-managed password. This deliberately gives runtime DDL and migration-metadata privileges in exchange for lower operational friction; `docker-compose.postgres-hardened.yml` and externally managed PostgreSQL deployments retain the separate administrator, migrator, and runtime role boundary from ADR 0004, and existing three-role Compose deployments upgrade through the hardened descriptor rather than collapsing roles. diff --git a/docs/architecture.md b/docs/architecture.md index 24a88e40..d0a8700c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,7 +158,7 @@ Cloud Auth stores hosted credentials and a selected workspace. `caplets attach` The public CLI package is `caplets`. The native packages are `@caplets/opencode` and `@caplets/pi`. -The repo includes a source-build `Dockerfile` and `docker-compose.yml` for self-hosting the HTTP service. Release workflows publish npm packages and can publish the service image. +The repo includes a source-build `Dockerfile` and three standalone Compose deployment descriptors for SQLite, convenient PostgreSQL, and hardened single-host PostgreSQL. Release workflows publish the service image and matching Compose files as GitHub Release assets. ## Benchmark Architecture diff --git a/docs/operations/sql-authoritative-host-state.md b/docs/operations/sql-authoritative-host-state.md index 33beb220..c1bcccfe 100644 --- a/docs/operations/sql-authoritative-host-state.md +++ b/docs/operations/sql-authoritative-host-state.md @@ -122,9 +122,9 @@ caplets storage schema-migrate ``` Use a DDL-capable migrator URL for that job. Runtime nodes use a different DML-only URL and start -only after the job exits successfully. The following is the concrete privilege boundary used by -`deploy/postgres/init-caplets-roles.sh`; substitute identifiers through your provisioning tool -rather than interpolating untrusted strings into SQL: +only after the job exits successfully. The hardened Compose reference reconciles this boundary with +the packaged `deploy/postgres/provision-roles.mjs` helper; substitute identifiers through your +provisioning tool rather than interpolating untrusted strings into SQL: ```sql REVOKE ALL ON DATABASE caplets FROM PUBLIC; @@ -163,9 +163,9 @@ FROM caplets_runtime; GRANT SELECT ON TABLE caplets_prod.caplets_schema TO caplets_runtime; ``` -Default privileges apply again when future migration objects are created, so this is part of the -one-shot migration job, not a one-time bootstrap. The Compose overlay runs -`deploy/postgres/finalize-runtime-grants.mjs` after `schema-migrate`. +Default privileges apply again when future migration objects are created, so finalizing runtime +grants is part of every one-shot migration job. The hardened Compose deployment runs the packaged +`finalize-runtime-grants.mjs` helper after `schema-migrate`. `LISTEN`/`NOTIFY` and advisory-lock functions do not require table ownership. Do not grant the runtime role `CREATE`, `TRUNCATE`, schema ownership, role administration, or migration credentials. @@ -178,40 +178,154 @@ The PostgreSQL documentation defines the relevant [`ALTER DEFAULT PRIVILEGES`](https://www.postgresql.org/docs/17/sql-alterdefaultprivileges.html), and [schema/search-path](https://www.postgresql.org/docs/17/ddl-schemas.html) behavior. -### Docker Compose overlay +### Standalone Docker Compose deployments -`docker-compose.yml` remains the default SQLite, single-node deployment. The additive -`docker-compose.postgres.yml` provides: +The release publishes three alternative, standalone deployment descriptors. Download and run +exactly one; do not combine them as overlays: -- a PostgreSQL 17.6 service with `pg_isready` health; -- initialization of separate administrator, migrator, and runtime roles; -- a one-shot `caplets-postgres-migrate` service; -- `service_completed_successfully` gating before the runtime service starts; and -- ephemeral, mode-`0600` config rendering so URLs are not committed. +```sh +curl -fLO https://github.com/spiritledsoftware/caplets/releases/latest/download/docker-compose.yml +curl -fLO https://github.com/spiritledsoftware/caplets/releases/latest/download/docker-compose.postgres.yml +curl -fLO https://github.com/spiritledsoftware/caplets/releases/latest/download/docker-compose.postgres-hardened.yml +``` + +The release assets use published Caplets images and contain no local build context or checkout bind +mount. Set `CAPLETS_IMAGE` to use a version, digest, or explicitly built local image. -Put three distinct, randomly generated passwords in an owner-only `.env` file: +#### SQLite convenience deployment -```dotenv -CAPLETS_POSTGRES_ADMIN_PASSWORD= -CAPLETS_POSTGRES_MIGRATOR_PASSWORD= -CAPLETS_POSTGRES_RUNTIME_PASSWORD= -CAPLETS_POSTGRES_SCHEMA=caplets +`docker-compose.yml` needs no required environment variables: + +```sh +docker compose -f docker-compose.yml config --quiet +docker compose -f docker-compose.yml up -d --wait ``` -Then validate and start the merged deployment: +It binds `127.0.0.1:5387` and persists config, state, and SQLite data in `caplets-data`. + +#### PostgreSQL convenience deployment + +`docker-compose.postgres.yml` is for fresh deployments. It deliberately uses one `caplets` owner +role for initialization, migration, and runtime access. The runtime credential can therefore apply +DDL and modify migration metadata; use the hardened deployment when that boundary is unacceptable. +The one-shot provisioning helper creates that non-superuser owner, then replaces the bootstrap +`postgres` password with a discarded random value so the runtime credential cannot authenticate as +the cluster superuser. + +Create one owner-only password file for Compose interpolation, then start the standalone topology: ```sh +umask 077 +printf 'CAPLETS_POSTGRES_PASSWORD=%s\n' "$(openssl rand -base64 32)" > .env chmod 600 .env -docker compose -f docker-compose.yml -f docker-compose.postgres.yml config --quiet -docker compose -f docker-compose.yml -f docker-compose.postgres.yml up --build -d +docker compose -f docker-compose.postgres.yml config --quiet +docker compose -f docker-compose.postgres.yml up -d --wait +``` + +The PostgreSQL image defaults to `postgres:17-bookworm`. Set `CAPLETS_POSTGRES_IMAGE` to select a +different full image reference. The migration job must complete successfully before runtime starts. + +Do not point this file at a volume initialized by the former three-role overlay. + +#### Hardened PostgreSQL reference + +`docker-compose.postgres-hardened.yml` is a hardened single-Docker-host reference, not a +high-availability topology. It retains separate administrator, migrator, and runtime roles, exact +image defaults, file-backed secrets, an internal database network, read-only non-root Caplets +containers, dropped capabilities, bounded logs, and a 60-second PostgreSQL shutdown grace period. + +Create three owner-only host secret files: + +```sh +mkdir -p secrets +chmod 700 secrets +umask 077 +openssl rand -base64 32 > secrets/postgres-admin-password +openssl rand -base64 32 > secrets/postgres-migrator-password +openssl rand -base64 32 > secrets/postgres-runtime-password +chmod 600 secrets/postgres-*-password +docker compose -f docker-compose.postgres-hardened.yml config --quiet +docker compose -f docker-compose.postgres-hardened.yml up -d --wait +``` + +The short-lived `caplets-postgres-secrets` service copies each Compose secret into a role-scoped +Docker volume owned by the non-root Caplets user. Runtime mounts only the runtime credential. +PostgreSQL and the migration service attach only to the internal database network; runtime also +attaches to an egress network. PostgreSQL has no published host port, and Caplets binds loopback by +default. Put remote access and TLS behind an operator-managed reverse proxy or private network. + +Existing users of the former two-file, three-role overlay can reuse its project-scoped volumes: + +1. Keep the same Compose project name or `-p` value. +2. Copy the existing administrator, migrator, and runtime password values into the three secret + files above instead of generating replacements. +3. Stop the old overlay without `--volumes`. +4. Start only `docker-compose.postgres-hardened.yml`. + +The administrator secret must match the credential already stored in PostgreSQL. Changing its file +cannot rotate that credential because the old value is needed to authenticate. Rotate it over the +container's protected loopback connection, then replace the file and recreate the affected services +in this order: + +```sh +set -eu +new_admin_secret=$(mktemp secrets/postgres-admin-password.XXXXXX) +chmod 600 "$new_admin_secret" +openssl rand -base64 32 > "$new_admin_secret" +new_admin_password=$(cat "$new_admin_secret") + +docker compose -f docker-compose.postgres-hardened.yml exec -T caplets-postgres \ + sh -ec 'export PGPASSWORD="$(cat /run/secrets/caplets_postgres_admin_password)" + exec psql --no-psqlrc --host 127.0.0.1 --username caplets_admin \ + --dbname "$POSTGRES_DB" --set=ON_ERROR_STOP=1' < logs --tail 100 +``` + +Stop containers while preserving data: + +```sh +docker compose -f down +``` + +For convenience deployments, pull the selected moving image before recreating services: + +```sh +docker compose -f pull +docker compose -f up -d --wait ``` -The role initializer runs only when the PostgreSQL data volume is first created. Editing `.env` does -not rotate roles in an existing database. Use `ALTER ROLE ... PASSWORD` through a protected -administrator session, update the deployment secret, and restart the affected job/nodes. +The hardened file defaults to exact image versions. Before changing either image reference, take a +database backup, verify a restore, update the exact tag or digest, and rerun `up -d --wait`. Schema +migrations are forward-only; rolling back a container image does not roll back the schema. -The overlay mounts the same `caplets-data` volume into runtime replicas, so its global Caplet File -manifest is shared on one Docker host. Do not treat a local Docker volume as cross-host storage. +The supplied topology does not provide backups, certificate management, monitoring, or arbitrary +resource limits. Treat `caplets-data` and `caplets-postgres-data` as single-host Docker volumes, not +cross-host or high-availability storage. ### Readiness and fail-closed behavior @@ -466,11 +580,11 @@ store credentials: caplets storage status --json ``` -For the Compose overlay before runtime cutover: +For the hardened Compose reference before runtime cutover: ```sh -docker compose -f docker-compose.yml -f docker-compose.postgres.yml run --rm --no-deps caplets \ - /bin/sh -ec 'node /usr/local/bin/render-caplets-postgres-config.mjs runtime && node dist/index.js storage status --json' +docker compose -f docker-compose.postgres-hardened.yml run --rm --no-deps caplets \ + /bin/sh -ec 'node /usr/local/lib/caplets/postgres/render-config.mjs runtime && node dist/index.js storage status --json' ``` Require ready database/schema health, expected Caplet Record and asset counts, object-store diff --git a/docs/specs/2026-07-18-standalone-compose-deployments.md b/docs/specs/2026-07-18-standalone-compose-deployments.md new file mode 100644 index 00000000..6585f237 --- /dev/null +++ b/docs/specs/2026-07-18-standalone-compose-deployments.md @@ -0,0 +1,235 @@ +# Standalone Docker Compose Deployments + +## Summary + +Distribute Caplets Docker Compose deployments that run from a downloaded Compose file without a Git checkout, local image build, or bind-mounted repository scripts. The repository will provide three mutually exclusive, standalone deployment descriptors: + +- `docker-compose.yml` for a convenient SQLite deployment; +- `docker-compose.postgres.yml` for a convenient, fresh PostgreSQL deployment using one database role and one password; and +- `docker-compose.postgres-hardened.yml` for a hardened single-Docker-host PostgreSQL reference using separate administrator, migrator, and runtime roles. + +The runtime and one-shot PostgreSQL migration job use the same published Caplets image so their schema expectations cannot drift. All deployment helpers required by the PostgreSQL descriptors are packaged in that image. + +## Goals + +- Let a user download one Compose file and start the selected deployment without the Caplets source tree. +- Make each Compose file complete rather than layering the PostgreSQL topology over `docker-compose.yml`. +- Use published multi-architecture Caplets images by default while allowing full image-reference overrides. +- Preserve explicit, successful pre-start PostgreSQL migration gating. +- Provide a low-configuration PostgreSQL option with one user-managed password. +- Provide a separate hardened reference that demonstrates least-privilege database roles, file-backed secrets, network separation, and container restrictions. +- Publish the three Compose files as GitHub Release assets at the same release boundary as the Caplets image. +- Preserve a practical upgrade path for existing three-role PostgreSQL Compose deployments. + +## Non-Goals + +- Do not retain local `build:` sections in the distributed Compose files. +- Do not support combining the SQLite and PostgreSQL files as Compose overlays. +- Do not automatically convert an existing three-role PostgreSQL database into the convenience one-role topology. +- Do not make the convenience PostgreSQL topology a replacement for the broader separate-role architectural recommendation. +- Do not make the hardened descriptor a multi-host or universally sufficient production platform. +- Do not bundle TLS termination, certificate automation, centralized logging, monitoring, backup storage, restore automation, or a reverse proxy. +- Do not invent universal CPU or memory limits. +- Do not add a public Caplets CLI command for the supplied Compose topology's role provisioning. +- Do not add a separate migrator image. + +## Deployment Artifact Contract + +The three Compose files are alternatives. A user runs exactly one file for a Compose project. Combining them is unsupported. + +The files do not set a top-level Compose project name. Normal directory-based naming and explicit `docker compose -p NAME` overrides remain available. This preserves multiple installations on one host and avoids silently redirecting existing project-scoped volumes. + +All descriptors retain the existing `caplets` service name, `caplets-data` volume key, loopback HTTP binding default, configurable bind address and port, and runtime health check. PostgreSQL descriptors retain the `caplets-postgres`, `caplets-postgres-migrate`, and `caplets-postgres-data` names. + +The convenience descriptors may load an optional `.env` file into the Caplets runtime for backend-specific environment configuration. The hardened descriptor does not pass an unrestricted `.env` into containers; it declares operational inputs explicitly and leaves additional backend credentials to Caplets Vault or an operator-owned Compose override. + +## Published Caplets Image + +`docker-compose.yml` and `docker-compose.postgres.yml` use: + +```yaml +image: ${CAPLETS_IMAGE:-ghcr.io/spiritledsoftware/caplets:latest} +``` + +A user may set `CAPLETS_IMAGE` to a version tag, local tag, or digest. The PostgreSQL runtime and migration services must reference the same interpolation so one invocation cannot select different application versions. + +The distributed files contain no `build:` section. A developer who needs a source build explicitly builds and selects it: + +```sh +docker build -t caplets:local . +CAPLETS_IMAGE=caplets:local docker compose up +``` + +The hardened descriptor defaults to an exact Caplets version tag. The checked-in default is synchronized with the CLI/image package version during the Changesets versioning workflow, and each published release asset names the image version from that release. `CAPLETS_IMAGE` remains a full-reference override so operators may select a digest. + +## SQLite Convenience Deployment + +`docker-compose.yml` is a complete SQLite deployment containing the Caplets runtime service and `caplets-data` volume. It has no required configuration variables. It pulls the published Caplets image when that image is not already present and starts with the image's normal initialization and HTTP-serving behavior. + +The default bind remains `127.0.0.1:5387`. `CAPLETS_BIND_ADDRESS`, `CAPLETS_PORT`, `CAPLETS_SERVER_URL`, and existing runtime settings remain explicit overrides. + +## PostgreSQL Convenience Deployment + +`docker-compose.postgres.yml` is a complete deployment containing PostgreSQL, the one-shot migration service, the Caplets runtime, `caplets-postgres-data`, and `caplets-data`. + +It is intended for fresh deployments. It uses one PostgreSQL login role named `caplets` for database ownership, schema migration, and runtime access. This role owns the configured database and schema and therefore retains DDL and migration-metadata privileges at runtime. That is an explicit usability trade-off, not the hardened security boundary. + +The only required credential is: + +```text +CAPLETS_POSTGRES_PASSWORD +``` + +The PostgreSQL image defaults to the moving PostgreSQL 17 Bookworm tag and permits a full-reference override: + +```yaml +image: ${CAPLETS_POSTGRES_IMAGE:-postgres:17-bookworm} +``` + +`CAPLETS_POSTGRES_DATABASE` and `CAPLETS_POSTGRES_SCHEMA` remain optional and default to `caplets`. PostgreSQL is not published to a host port. Its health check uses `pg_isready`. + +The migration service waits for PostgreSQL health, renders an ephemeral mode-`0600` Caplets configuration, and runs the existing public command: + +```sh +node dist/index.js storage schema-migrate +``` + +The runtime service starts only after the migration service exits successfully. A failed migration leaves runtime stopped and the failed one-shot container available for log inspection. + +## Hardened PostgreSQL Reference + +`docker-compose.postgres-hardened.yml` is a standalone, hardened single-Docker-host reference. It does not claim high availability or cover every production concern. + +### Exact Images + +The descriptor defaults to exact Caplets and PostgreSQL image versions rather than moving tags. The initial PostgreSQL default is `postgres:17.6-bookworm`; later patch upgrades are deliberate reviewed changes. `CAPLETS_IMAGE` and `CAPLETS_POSTGRES_IMAGE` may override either full image reference, including with a digest. + +The Caplets exact default is updated automatically as part of package versioning so a release asset defaults to the Caplets image published in the same release. + +### Roles And Secrets + +The hardened topology retains fixed roles: + +- `caplets_admin` initializes and administers the dedicated database; +- `caplets_migrator` owns the application schema and applies migrations; and +- `caplets_runtime` receives only runtime data access. + +It requires three host-side secret files, configurable by explicit path variables and defaulting to documented files under `./secrets/`: + +- administrator password; +- migrator password; and +- runtime password. + +Compose mounts these through top-level `secrets`. PostgreSQL receives its administrator credential through `POSTGRES_PASSWORD_FILE`. A no-network, one-shot preparation service copies each source into a role-scoped Docker volume as a mode-`0400` file owned by the non-root Caplets user; this avoids depending on host and container UID equality for bind-mounted Compose secrets. The preparation service runs as root with only the `CHOWN`, `DAC_OVERRIDE`, and `FOWNER` capabilities, then exits. Migration mounts all three prepared volumes read-only, while runtime mounts only its runtime credential. Database credentials must not appear in container environment values or command arguments. + +The one-shot migration service connects as administrator to idempotently create or reconcile the migrator/runtime roles, schema, passwords, ownership, default privileges, and database grants. It reconnects as migrator to apply the schema and then finalizes runtime grants. Administrator and migrator credentials never enter the runtime container. + +Editing an administrator secret file cannot rotate the PostgreSQL administrator password by itself because the old password is needed to authenticate. Documentation must provide the explicit protected rotation procedure. Migrator and runtime credentials are reconciled after successful administrator authentication. + +### Network Boundary + +The hardened topology uses two networks: + +- an internal database network shared by PostgreSQL, migrator, and runtime; and +- a runtime network attached only to Caplets to preserve outbound API access and the loopback-bound HTTP port. + +PostgreSQL and the migration container have no general outbound network path. PostgreSQL has no published host port. Caplets binds to `127.0.0.1:5387` by default. Remote exposure and TLS belong to an operator-managed reverse proxy or private network outside this descriptor. + +### Container Restrictions + +The Caplets runtime and migration containers run as the image's non-root user with: + +- a read-only root filesystem; +- a bounded writable temporary filesystem; +- writable `/data` only for the runtime service; +- all Linux capabilities dropped; and +- `no-new-privileges` enabled. + +PostgreSQL retains the official image's required initialization privilege transition and writable paths. Additional PostgreSQL restrictions may be included only when first initialization and restart are verified with a fresh named volume. PostgreSQL receives a 60-second shutdown grace period. + +All hardened services use bounded local Docker logs, initially `max-size: 10m` and `max-file: 3`. Operators may replace the log driver. The reference does not set CPU or memory limits because safe values are workload- and host-specific. + +### Operational Contract + +Schema migrations are forward-only. Changing an exact image pin requires a verified database backup first; rolling back a container image does not roll back the schema. + +Backups and tested restores are mandatory for a real deployment, but the descriptor does not include a local backup sidecar. Backup destination, encryption, retention, off-host durability, and restore testing remain operator responsibilities. + +The descriptor explicitly documents that it is single-host. A Docker named volume is not cross-host storage or a high-availability database design. + +## Packaged PostgreSQL Deployment Helpers + +The Caplets runtime image contains private deployment helpers for: + +- rendering PostgreSQL Caplets configuration from environment or file-backed credentials; +- idempotently provisioning the hardened role/schema boundary; and +- finalizing runtime grants after migrations. + +These helpers are a private container interface for the supplied deployment recipes, not public CLI commands. They validate PostgreSQL schema identifiers, avoid interpolating untrusted values as SQL identifiers, write generated configuration only to temporary mode-`0600` files, and never log connection strings or credentials. + +The convenience migration path uses the renderer and existing `storage schema-migrate` command but does not provision or finalize separate roles. The hardened migration path provisions roles, renders the migrator configuration, invokes the same schema-migration command, and finalizes runtime grants. + +The current bind-mounted `deploy/postgres/init-caplets-roles.sh` flow becomes obsolete. No Compose service may mount deployment scripts from the checkout. + +## Existing Deployment Compatibility + +Service names, volume keys, database/schema defaults, and hardened role names remain stable. Existing users of the current two-file, three-role overlay move to `docker-compose.postgres-hardened.yml` under the same Compose project name and reuse their existing volumes. + +Before that cutover, existing environment password values are copied into the three hardened secret files. The provisioning job reconciles the existing roles and grants. The administrator secret must match the credential already stored in PostgreSQL. + +The one-role convenience file is not an in-place upgrade target for those volumes. Documentation must warn that selecting it under the same Compose project name as an existing three-role deployment is unsupported. There is no automatic role collapse, ownership transfer, or password conversion. + +## Release Distribution + +The repository copies are the source for all three deployment descriptors. When the `caplets` CLI package and Docker image are published, the release workflow uploads these assets to the corresponding `caplets@VERSION` GitHub Release: + +- `docker-compose.yml`; +- `docker-compose.postgres.yml`; and +- `docker-compose.postgres-hardened.yml`. + +The documented convenience URLs are: + +```text +https://github.com/spiritledsoftware/caplets/releases/latest/download/docker-compose.yml +https://github.com/spiritledsoftware/caplets/releases/latest/download/docker-compose.postgres.yml +https://github.com/spiritledsoftware/caplets/releases/latest/download/docker-compose.postgres-hardened.yml +``` + +Release-asset upload occurs only after the matching Caplets image is successfully published. Raw files from `main` are development sources, not the official atomic distribution boundary. + +## Documentation And Architecture Records + +Operational documentation must: + +- present the three descriptors as alternatives; +- provide checkout-free download, configuration, validation, startup, health, logs, upgrade, and shutdown commands; +- state the one-role convenience trade-off prominently; +- describe hardened secret-file creation and permissions without publishing example secrets; +- explain the existing three-role cutover path; +- document administrator password rotation limitations; +- require backups and restore testing before hardened upgrades; +- state the single-host, no-bundled-TLS, and no-HA boundaries; and +- replace all instructions that combine the two existing files or use `up --build`. + +A new ADR records that the bundled convenience PostgreSQL descriptor deliberately uses one owner role for lower operational friction, while hardened and externally managed deployments retain the separate-role recommendation from ADR 0004. This deployment decision does not add a product-domain glossary term to `CONTEXT.md`. + +## Acceptance Criteria + +1. Each Compose file validates independently and does not reference another Compose file. +2. A temporary directory containing only downloaded `docker-compose.yml` can start a healthy SQLite Caplets service without a Docker build. +3. A temporary directory containing only downloaded `docker-compose.postgres.yml` and one password can initialize fresh volumes, complete migration, and start a healthy PostgreSQL-backed Caplets service. +4. The convenience PostgreSQL database contains one application login role, `caplets`, used by migration and runtime. +5. A temporary directory containing only the hardened descriptor and three secret files can initialize fresh volumes, complete provisioning/migration, and start a healthy PostgreSQL-backed Caplets service. +6. Hardened runtime credentials cannot create or alter schema objects, mutate migration metadata, authenticate as administrator/migrator, or read secret files belonging only to those roles. +7. Hardened runtime retains the required CRUD and sequence privileges for ordinary Caplets operations. +8. PostgreSQL and migration services in the hardened topology have no published ports or general outbound network route; Caplets retains outbound access and binds HTTP to loopback by default. +9. Hardened Caplets containers run non-root with read-only roots, dropped capabilities, `no-new-privileges`, and only their declared writable mounts. +10. Migration failure prevents runtime startup in both PostgreSQL descriptors. +11. Existing three-role volumes start successfully through the hardened descriptor when supplied with their existing credentials and Compose project name. +12. The convenience descriptor refuses no credentials implicitly: absent required passwords or secret files fail during Compose validation or service startup with a specific error. +13. Runtime and migration resolve the same Caplets image reference in each PostgreSQL descriptor. +14. No distributed descriptor contains a local build context or checkout bind mount. +15. The release workflow uploads all three descriptors to the matching `caplets@VERSION` GitHub Release after publishing the image. +16. The hardened release asset defaults to that release's exact Caplets image version and an exact PostgreSQL patch version. +17. Repository documentation contains no remaining supported command that combines `docker-compose.yml` with `docker-compose.postgres.yml` or requires `up --build` for deployment. +18. Focused Compose configuration checks and end-to-end container smoke tests cover SQLite, convenience PostgreSQL, hardened fresh initialization, migration gating, and existing three-role compatibility. diff --git a/package.json b/package.json index b44ba37f..132f9206 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "clean-install": "rm -rf **/node_modules pnpm-lock.yaml && pnpm install", "code-mode:check-api": "node scripts/generate-code-mode-runtime-api.mjs --check && node scripts/generate-code-mode-platform-runtime.mjs --check", "code-mode:generate-api": "node scripts/generate-code-mode-runtime-api.mjs && node scripts/generate-code-mode-platform-runtime.mjs", + "compose:check": "node scripts/sync-compose-image-version.mjs --check", + "compose:smoke": "node scripts/smoke-compose-deployments.mjs", "dev": "tsx ./scripts/dev.ts", "docs:check": "tsx ./scripts/generate-docs-reference.ts --check && tsx ./scripts/check-public-docs.ts", "docs:generate": "tsx ./scripts/generate-docs-reference.ts", @@ -42,8 +44,8 @@ "telemetry:check-web-env": "tsx ./scripts/check-web-observability-env.ts", "telemetry:prepare-release-env": "tsx ./scripts/check-telemetry-release-env.ts --write-bundled-intake", "typecheck": "tsgo --noEmit && turbo typecheck", - "verify": "pnpm format:check && pnpm lint && pnpm code-mode:check-api && pnpm schema:check && pnpm storage:check && pnpm docs:check && pnpm typecheck && pnpm test && pnpm benchmark:check && pnpm build", - "version-packages": "changeset version && oxlint --fix --quiet && oxfmt --write ." + "verify": "pnpm format:check && pnpm lint && pnpm code-mode:check-api && pnpm schema:check && pnpm storage:check && pnpm compose:check && pnpm docs:check && pnpm typecheck && pnpm test && pnpm benchmark:check && pnpm build", + "version-packages": "changeset version && node scripts/sync-compose-image-version.mjs --write && oxlint --fix --quiet && oxfmt --write ." }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/packages/core/test/postgres-deployment-config.test.ts b/packages/core/test/postgres-deployment-config.test.ts new file mode 100644 index 00000000..6062f0ec --- /dev/null +++ b/packages/core/test/postgres-deployment-config.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const renderConfigPath = fileURLToPath( + new URL("../../../deploy/postgres/render-config.mjs", import.meta.url), +); + +describe("PostgreSQL deployment config", () => { + it("renders a runtime connection from a file-backed generic role credential", () => { + const config = renderRuntimeConfig({ + credential: "CAPLETS_POSTGRES_PASSWORD", + password: "file-backed password", + user: "caplets", + }); + const connection = new URL(config.storage.connectionString); + expect({ + type: config.storage.type, + username: connection.username, + password: connection.password, + hostname: connection.hostname, + database: connection.pathname, + schema: config.storage.schema, + }).toEqual({ + type: "postgres", + username: "caplets", + password: "file-backed%20password", + hostname: "caplets-postgres", + database: "/caplets", + schema: "caplets", + }); + }); + + it("renders the hardened runtime role from its secret file", () => { + const config = renderRuntimeConfig({ + credential: "CAPLETS_POSTGRES_RUNTIME_PASSWORD", + password: "runtime secret", + }); + const connection = new URL(config.storage.connectionString); + expect({ + username: connection.username, + password: connection.password, + }).toEqual({ + username: "caplets_runtime", + password: "runtime%20secret", + }); + }); +}); + +function renderRuntimeConfig(input: { credential: string; password: string; user?: string }): { + storage: { connectionString: string; schema: string; type: string }; +} { + const directory = mkdtempSync(join(tmpdir(), "caplets-postgres-config-")); + const passwordPath = join(directory, "password"); + const configPath = join(directory, "config.json"); + writeFileSync(passwordPath, `${input.password}\n`, { mode: 0o600 }); + + try { + execFileSync(process.execPath, [renderConfigPath, "runtime"], { + env: { + CAPLETS_CONFIG: configPath, + ...(input.user ? { CAPLETS_POSTGRES_USER: input.user } : {}), + [`${input.credential}_FILE`]: passwordPath, + }, + }); + return JSON.parse(readFileSync(configPath, "utf8")); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/scripts/smoke-compose-deployments.mjs b/scripts/smoke-compose-deployments.mjs new file mode 100755 index 00000000..2090ad1c --- /dev/null +++ b/scripts/smoke-compose-deployments.mjs @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const root = mkdtempSync(join(tmpdir(), "caplets-compose-smoke-")); +const image = process.env.CAPLETS_SMOKE_IMAGE || "caplets:compose-smoke"; +const projects = []; +const prefix = `caplets-smoke-${process.pid}`; + +try { + if (!process.env.CAPLETS_SMOKE_IMAGE) { + phase("Build Caplets image"); + docker(["build", "--tag", image, repoRoot], { stdio: "inherit" }); + } + + await smokeSqlite(); + await smokeConveniencePostgres(); + await smokeHardenedPostgres(); + smokeMigrationGates(); + await smokeLegacyCompatibility(); + console.log("Compose smoke deployments passed."); +} finally { + for (const project of projects.reverse()) { + compose(project, ["down", "--volumes", "--remove-orphans"], { + allowFailure: true, + stdio: "ignore", + }); + } + rmSync(root, { recursive: true, force: true }); +} + +async function smokeSqlite() { + phase("SQLite standalone deployment"); + const project = fixture("sqlite", "docker-compose.yml"); + compose(project, ["config", "--quiet"]); + compose(project, ["up", "-d", "--wait"], { stdio: "inherit" }); + assert.equal((await health(project)).backend, "sqlite"); +} + +async function smokeConveniencePostgres() { + phase("Convenience PostgreSQL deployment"); + const env = { CAPLETS_POSTGRES_PASSWORD: "convenience-smoke-secret" }; + const project = fixture("postgres", "docker-compose.postgres.yml", env); + compose(project, ["config", "--quiet"]); + compose(project, ["up", "-d", "--wait"], { stdio: "inherit" }); + assert.equal((await health(project)).backend, "postgres"); + const roles = compose(project, [ + "exec", + "-T", + "caplets-postgres", + "psql", + "--username", + "caplets", + "--dbname", + "caplets", + "--tuples-only", + "--no-align", + "--command", + "SELECT r.rolname || ':' || r.rolsuper::text || ':' || r.rolcreatedb::text || ':' || r.rolcreaterole::text || ':' || r.rolreplication::text || ':' || r.rolbypassrls::text || ':' || pg_get_userbyid(d.datdba) || ':' || pg_get_userbyid(n.nspowner) FROM pg_roles r CROSS JOIN pg_database d CROSS JOIN pg_namespace n WHERE r.rolname='caplets' AND d.datname=current_database() AND n.nspname='caplets'", + ]).trim(); + assert.equal(roles, "caplets:false:false:false:false:false:caplets:caplets"); + const bootstrapLogin = composeResult(project, [ + "exec", + "-T", + "-e", + "PGPASSWORD=convenience-smoke-secret", + "caplets-postgres", + "psql", + "--host", + "caplets-postgres", + "--username", + "postgres", + "--dbname", + "caplets", + "--command", + "SELECT 1", + ]); + assert.notEqual(bootstrapLogin.status, 0); + assert.match(bootstrapLogin.stderr, /password authentication failed/u); + compose(project, ["run", "--rm", "--no-deps", "caplets-postgres-migrate"]); + + const missingPassword = fixture("postgres-missing", "docker-compose.postgres.yml"); + const missingEnv = deploymentEnv({}); + delete missingEnv.CAPLETS_POSTGRES_PASSWORD; + const result = dockerResult(composeCommand(missingPassword, ["config", "--quiet"]), { + cwd: missingPassword.directory, + env: missingEnv, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /CAPLETS_POSTGRES_PASSWORD/u); +} + +async function smokeHardenedPostgres() { + phase("Hardened PostgreSQL deployment"); + const project = hardenedFixture("hardened"); + compose(project, ["config", "--quiet"]); + compose(project, ["up", "-d", "--wait"], { stdio: "inherit" }); + assert.equal((await health(project)).backend, "postgres"); + + for (const service of [ + "caplets-postgres", + "caplets-postgres-secrets", + "caplets-postgres-migrate", + "caplets", + ]) { + const logConfig = inspect(`${project.name}-${service}-1`).HostConfig.LogConfig; + assert.equal(logConfig.Type, "local"); + assert.deepEqual(logConfig.Config, { "max-file": "3", "max-size": "10m" }); + } + + const runtime = inspect(`${project.name}-caplets-1`); + assert.equal(runtime.Config.User, "node"); + assert.equal(runtime.HostConfig.ReadonlyRootfs, true); + assert.deepEqual(runtime.HostConfig.CapDrop, ["ALL"]); + assert.ok(runtime.HostConfig.SecurityOpt.includes("no-new-privileges:true")); + assert.deepEqual(Object.keys(runtime.NetworkSettings.Networks).sort(), [ + `${project.name}_database`, + `${project.name}_runtime`, + ]); + assert.equal(inspectNetwork(`${project.name}_database`).Internal, true); + + const grants = compose(project, [ + "exec", + "-T", + "caplets-postgres", + "psql", + "--username", + "caplets_admin", + "--dbname", + "caplets", + "--tuples-only", + "--no-align", + "--command", + "SELECT bool_and(has_table_privilege('caplets_runtime', c.oid, 'SELECT,INSERT,UPDATE,DELETE')) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='caplets' AND c.relkind='r' AND c.relname NOT IN ('caplets_migrations','caplets_schema')", + ]).trim(); + assert.equal(grants, "t"); + + const denied = composeResult(project, [ + "exec", + "-T", + "-e", + "PGPASSWORD=runtime-smoke-secret", + "caplets-postgres", + "psql", + "--host", + "caplets-postgres", + "--username", + "caplets_runtime", + "--dbname", + "caplets", + "--set", + "ON_ERROR_STOP=1", + "--command", + "CREATE TABLE caplets.runtime_forbidden(id integer)", + ]); + assert.notEqual(denied.status, 0); + assert.match(denied.stderr, /permission denied for schema caplets/u); +} + +function smokeMigrationGates() { + phase("PostgreSQL migration gates"); + const convenience = fixture("postgres-gate", "docker-compose.postgres.yml", { + CAPLETS_POSTGRES_PASSWORD: "gate-smoke-secret", + CAPLETS_POSTGRES_SCHEMA: "Invalid", + }); + const convenienceResult = composeResult(convenience, ["up", "-d", "--wait"]); + assert.notEqual(convenienceResult.status, 0); + assert.equal(containerState(`${convenience.name}-caplets-1`), "created"); + + const hardened = hardenedFixture("hardened-gate", { CAPLETS_POSTGRES_SCHEMA: "Invalid" }); + const hardenedResult = composeResult(hardened, ["up", "-d", "--wait"]); + assert.notEqual(hardenedResult.status, 0); + assert.equal(containerState(`${hardened.name}-caplets-1`), "created"); +} + +async function smokeLegacyCompatibility() { + phase("Existing three-role deployment compatibility"); + const directory = join(root, "compat"); + mkdirSync(directory); + writeFileSync( + join(directory, "legacy.yml"), + `services:\n caplets-postgres:\n image: postgres:17.6-bookworm\n environment:\n POSTGRES_DB: caplets\n POSTGRES_USER: caplets_admin\n POSTGRES_PASSWORD: legacy-admin-secret\n volumes:\n - caplets-postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [CMD-SHELL, "pg_isready --username caplets_admin --dbname caplets"]\n interval: 2s\n timeout: 5s\n retries: 20\nvolumes:\n caplets-postgres-data:\n`, + ); + copyFileSync( + join(repoRoot, "docker-compose.postgres-hardened.yml"), + join(directory, "compose.yml"), + ); + writeSecrets(directory, "legacy"); + const project = register({ + directory, + env: deploymentEnv({ CAPLETS_PORT: "0" }), + file: "compose.yml", + name: `${prefix}-compat`, + }); + const legacy = { ...project, file: "legacy.yml" }; + compose(legacy, ["up", "-d", "--wait"], { stdio: "inherit" }); + compose(legacy, [ + "exec", + "-T", + "caplets-postgres", + "psql", + "--username", + "caplets_admin", + "--dbname", + "caplets", + "--set", + "ON_ERROR_STOP=1", + "--command", + legacyRoleSql(), + ]); + docker( + [ + "run", + "--rm", + "--network", + `${project.name}_default`, + "--env", + "CAPLETS_CONFIG=/tmp/caplets-migrator-config.json", + "--env", + "CAPLETS_POSTGRES_HOST=caplets-postgres", + "--env", + "CAPLETS_POSTGRES_DATABASE=caplets", + "--env", + "CAPLETS_POSTGRES_SCHEMA=caplets", + "--env", + "CAPLETS_POSTGRES_MIGRATOR_PASSWORD=legacy-migrator-secret", + "--entrypoint", + "/bin/sh", + image, + "-ec", + "node /usr/local/lib/caplets/postgres/render-config.mjs migrator; node dist/index.js storage schema-migrate; node /usr/local/lib/caplets/postgres/finalize-runtime-grants.mjs", + ], + { stdio: "inherit" }, + ); + compose(legacy, ["down"]); + compose(project, ["up", "-d", "--wait"], { stdio: "inherit" }); + assert.equal((await health(project)).backend, "postgres"); +} + +function fixture(label, source, overrides = {}) { + const directory = join(root, label); + mkdirSync(directory); + copyFileSync(join(repoRoot, source), join(directory, "compose.yml")); + return register({ + directory, + env: deploymentEnv({ CAPLETS_PORT: "0", ...overrides }), + file: "compose.yml", + name: `${prefix}-${label}`, + }); +} + +function hardenedFixture(label, overrides = {}) { + const project = fixture(label, "docker-compose.postgres-hardened.yml", overrides); + writeSecrets(project.directory, label.startsWith("compat") ? "legacy" : "smoke"); + return project; +} + +function writeSecrets(directory, kind) { + const values = + kind === "legacy" + ? ["legacy-admin-secret", "legacy-migrator-secret", "legacy-runtime-secret"] + : ["admin-smoke-secret", "migrator-smoke-secret", "runtime-smoke-secret"]; + const secretDirectory = join(directory, "secrets"); + mkdirSync(secretDirectory, { recursive: true }); + for (const [name, value] of [ + ["postgres-admin-password", values[0]], + ["postgres-migrator-password", values[1]], + ["postgres-runtime-password", values[2]], + ]) { + const path = join(secretDirectory, name); + writeFileSync(path, `${value}\n`); + chmodSync(path, 0o600); + } +} + +function register(project) { + projects.push(project); + return project; +} + +async function health(project) { + const address = compose(project, ["port", "caplets", "5387"]).trim(); + const response = await fetch(`http://${address}/v1/healthz`); + assert.equal(response.ok, true); + const body = await response.json(); + assert.equal(body.ready, true); + return body; +} + +function compose(project, args, options = {}) { + return docker(composeCommand(project, args), { + cwd: project.directory, + env: project.env, + ...options, + }); +} + +function composeResult(project, args) { + return dockerResult(composeCommand(project, args), { + cwd: project.directory, + env: project.env, + }); +} + +function composeCommand(project, args) { + return ["compose", "-p", project.name, "-f", project.file, ...args]; +} + +function deploymentEnv(overrides) { + return { ...process.env, CAPLETS_IMAGE: image, ...overrides }; +} + +function docker(args, options = {}) { + try { + return execFileSync("docker", args, { + cwd: options.cwd || repoRoot, + encoding: "utf8", + env: options.env || process.env, + stdio: options.stdio || "pipe", + }); + } catch (error) { + if (options.allowFailure) return ""; + throw error; + } +} + +function dockerResult(args, options = {}) { + const result = spawnSync("docker", args, { + cwd: options.cwd || repoRoot, + encoding: "utf8", + env: options.env || process.env, + }); + return { + status: result.status, + stderr: result.stderr || "", + stdout: result.stdout || "", + }; +} + +function inspect(container) { + return JSON.parse(docker(["inspect", container]))[0]; +} + +function inspectNetwork(network) { + return JSON.parse(docker(["network", "inspect", network]))[0]; +} + +function containerState(container) { + return docker(["inspect", "--format", "{{.State.Status}}", container]).trim(); +} + +function phase(name) { + console.log(`\n==> ${name}`); +} + +function legacyRoleSql() { + return ` +REVOKE ALL ON DATABASE caplets FROM PUBLIC; +CREATE ROLE caplets_migrator LOGIN NOINHERIT PASSWORD 'legacy-migrator-secret'; +CREATE ROLE caplets_runtime LOGIN NOINHERIT PASSWORD 'legacy-runtime-secret'; +GRANT CONNECT, CREATE ON DATABASE caplets TO caplets_migrator; +GRANT CONNECT ON DATABASE caplets TO caplets_runtime; +CREATE SCHEMA caplets AUTHORIZATION caplets_migrator; +REVOKE ALL ON SCHEMA caplets FROM PUBLIC; +GRANT USAGE ON SCHEMA caplets TO caplets_runtime; +ALTER ROLE caplets_migrator IN DATABASE caplets SET search_path TO caplets; +ALTER ROLE caplets_runtime IN DATABASE caplets SET search_path TO caplets; +ALTER DEFAULT PRIVILEGES FOR ROLE caplets_migrator IN SCHEMA caplets GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO caplets_runtime; +ALTER DEFAULT PRIVILEGES FOR ROLE caplets_migrator IN SCHEMA caplets GRANT USAGE, SELECT ON SEQUENCES TO caplets_runtime; +`; +} diff --git a/scripts/sync-compose-image-version.mjs b/scripts/sync-compose-image-version.mjs new file mode 100755 index 00000000..d2b4bb83 --- /dev/null +++ b/scripts/sync-compose-image-version.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; + +const mode = process.argv[2]; +if (mode !== "--check" && mode !== "--write") { + throw new Error("usage: sync-compose-image-version.mjs <--check|--write>"); +} + +const packageVersion = JSON.parse(readFileSync("packages/cli/package.json", "utf8")).version; +const composePath = "docker-compose.postgres-hardened.yml"; +const compose = readFileSync(composePath, "utf8"); +const imagePattern = /(ghcr\.io\/spiritledsoftware\/caplets:)[^}\s]+(?=\})/gu; +const matches = [...compose.matchAll(imagePattern)]; +if (matches.length !== 3) { + throw new Error(`${composePath} must contain exactly three default Caplets image references`); +} + +const expectedImage = `ghcr.io/spiritledsoftware/caplets:${packageVersion}`; +const synchronized = compose.replace(imagePattern, expectedImage); +if (mode === "--write") { + if (synchronized !== compose) writeFileSync(composePath, synchronized); +} else if (synchronized !== compose) { + throw new Error(`${composePath} must default to ${expectedImage}; run pnpm version-packages`); +} From dae00126ff2e4fc3973c155b3cf9e0f7ef58eecc Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sun, 19 Jul 2026 05:28:21 -0400 Subject: [PATCH 2/2] fix: address standalone compose review --- Dockerfile | 2 ++ deploy/postgres/provision-roles.mjs | 39 ++++++++++++++++------------ deploy/postgres/render-config.mjs | 14 +++++----- docker-compose.postgres-hardened.yml | 8 +++--- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2895ccc4..40fb5af1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,8 @@ RUN mkdir -p /data/config /data/state && \ COPY --from=build --chown=node:root /deploy ./ COPY --from=build --chown=node:root /app/deploy/postgres/*.mjs /usr/local/lib/caplets/postgres/ +VOLUME ["/data"] + EXPOSE 5387 USER node diff --git a/deploy/postgres/provision-roles.mjs b/deploy/postgres/provision-roles.mjs index dbc4aff7..b635793f 100755 --- a/deploy/postgres/provision-roles.mjs +++ b/deploy/postgres/provision-roles.mjs @@ -45,6 +45,7 @@ try { async function provisionConvenienceRole(bootstrapRole) { const role = "caplets"; + const quotedRole = quoteIdentifier(role); const password = readCredential("CAPLETS_POSTGRES_PASSWORD"); if (bootstrapRole) { await reconcileRole(role, password); @@ -55,13 +56,13 @@ async function provisionConvenienceRole(bootstrapRole) { const quotedDatabase = quoteIdentifier(database); const quotedSchema = quoteIdentifier(schema); await client.query(`REVOKE ALL ON DATABASE ${quotedDatabase} FROM PUBLIC`); - await client.query(`ALTER DATABASE ${quotedDatabase} OWNER TO ${role}`); + await client.query(`ALTER DATABASE ${quotedDatabase} OWNER TO ${quotedRole}`); await client.query("REVOKE CREATE ON SCHEMA public FROM PUBLIC"); - await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema} AUTHORIZATION ${role}`); - await client.query(`ALTER SCHEMA ${quotedSchema} OWNER TO ${role}`); + await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema} AUTHORIZATION ${quotedRole}`); + await client.query(`ALTER SCHEMA ${quotedSchema} OWNER TO ${quotedRole}`); await client.query(`REVOKE ALL ON SCHEMA ${quotedSchema} FROM PUBLIC`); await client.query( - `ALTER ROLE ${role} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, + `ALTER ROLE ${quotedRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, ); if (bootstrapRole) await rotateBootstrapPassword(bootstrapRole); } @@ -104,6 +105,8 @@ async function rotateBootstrapPassword(role) { async function provisionHardenedRoles() { const migratorRole = "caplets_migrator"; const runtimeRole = "caplets_runtime"; + const quotedMigratorRole = quoteIdentifier(migratorRole); + const quotedRuntimeRole = quoteIdentifier(runtimeRole); const migratorPassword = readCredential("CAPLETS_POSTGRES_MIGRATOR_PASSWORD"); const runtimePassword = readCredential("CAPLETS_POSTGRES_RUNTIME_PASSWORD"); await reconcileRole(migratorRole, migratorPassword); @@ -112,36 +115,40 @@ async function provisionHardenedRoles() { const quotedDatabase = quoteIdentifier(database); const quotedSchema = quoteIdentifier(schema); await client.query(`REVOKE ALL ON DATABASE ${quotedDatabase} FROM PUBLIC`); - await client.query(`GRANT CONNECT, CREATE ON DATABASE ${quotedDatabase} TO ${migratorRole}`); - await client.query(`GRANT CONNECT ON DATABASE ${quotedDatabase} TO ${runtimeRole}`); + await client.query( + `GRANT CONNECT, CREATE ON DATABASE ${quotedDatabase} TO ${quotedMigratorRole}`, + ); + await client.query(`GRANT CONNECT ON DATABASE ${quotedDatabase} TO ${quotedRuntimeRole}`); await client.query("REVOKE CREATE ON SCHEMA public FROM PUBLIC"); - await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema} AUTHORIZATION ${migratorRole}`); - await client.query(`ALTER SCHEMA ${quotedSchema} OWNER TO ${migratorRole}`); + await client.query( + `CREATE SCHEMA IF NOT EXISTS ${quotedSchema} AUTHORIZATION ${quotedMigratorRole}`, + ); + await client.query(`ALTER SCHEMA ${quotedSchema} OWNER TO ${quotedMigratorRole}`); await client.query(`REVOKE ALL ON SCHEMA ${quotedSchema} FROM PUBLIC`); - await client.query(`GRANT USAGE ON SCHEMA ${quotedSchema} TO ${runtimeRole}`); + await client.query(`GRANT USAGE ON SCHEMA ${quotedSchema} TO ${quotedRuntimeRole}`); await client.query( - `ALTER ROLE ${migratorRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, + `ALTER ROLE ${quotedMigratorRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, ); await client.query( - `ALTER ROLE ${runtimeRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, + `ALTER ROLE ${quotedRuntimeRole} IN DATABASE ${quotedDatabase} SET search_path TO ${quotedSchema}`, ); await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} REVOKE ALL ON TABLES FROM PUBLIC`, + `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedMigratorRole} IN SCHEMA ${quotedSchema} REVOKE ALL ON TABLES FROM PUBLIC`, ); await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${runtimeRole}`, + `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedMigratorRole} IN SCHEMA ${quotedSchema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quotedRuntimeRole}`, ); await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} REVOKE ALL ON SEQUENCES FROM PUBLIC`, + `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedMigratorRole} IN SCHEMA ${quotedSchema} REVOKE ALL ON SEQUENCES FROM PUBLIC`, ); await client.query( - `ALTER DEFAULT PRIVILEGES FOR ROLE ${migratorRole} IN SCHEMA ${quotedSchema} GRANT USAGE, SELECT ON SEQUENCES TO ${runtimeRole}`, + `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedMigratorRole} IN SCHEMA ${quotedSchema} GRANT USAGE, SELECT ON SEQUENCES TO ${quotedRuntimeRole}`, ); } async function reconcileRole(role, password) { const existing = await client.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [role]); - if (existing.rowCount === 0) await client.query(`CREATE ROLE ${role}`); + if (existing.rowCount === 0) await client.query(`CREATE ROLE ${quoteIdentifier(role)}`); const statement = await client.query( "SELECT format('ALTER ROLE %I LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD %L', $1::text, $2::text) AS sql", [role, password], diff --git a/deploy/postgres/render-config.mjs b/deploy/postgres/render-config.mjs index bc65f69e..8cb0b670 100755 --- a/deploy/postgres/render-config.mjs +++ b/deploy/postgres/render-config.mjs @@ -15,12 +15,14 @@ if (mode !== "migrator" && mode !== "runtime") { const role = process.env.CAPLETS_POSTGRES_USER || (mode === "migrator" ? "caplets_migrator" : "caplets_runtime"); -const passwordName = - process.env.CAPLETS_POSTGRES_PASSWORD || process.env.CAPLETS_POSTGRES_PASSWORD_FILE - ? "CAPLETS_POSTGRES_PASSWORD" - : mode === "migrator" - ? "CAPLETS_POSTGRES_MIGRATOR_PASSWORD" - : "CAPLETS_POSTGRES_RUNTIME_PASSWORD"; +const hasGenericPassword = + Boolean(process.env.CAPLETS_POSTGRES_PASSWORD) || + Boolean(process.env.CAPLETS_POSTGRES_PASSWORD_FILE); +const passwordName = hasGenericPassword + ? "CAPLETS_POSTGRES_PASSWORD" + : mode === "migrator" + ? "CAPLETS_POSTGRES_MIGRATOR_PASSWORD" + : "CAPLETS_POSTGRES_RUNTIME_PASSWORD"; const password = readCredential(passwordName); const schema = postgresSchema(); const target = process.env.CAPLETS_CONFIG || "/tmp/caplets-config.json"; diff --git a/docker-compose.postgres-hardened.yml b/docker-compose.postgres-hardened.yml index 463108c4..c2525131 100644 --- a/docker-compose.postgres-hardened.yml +++ b/docker-compose.postgres-hardened.yml @@ -58,9 +58,11 @@ services: - -ec command: - | - install --owner=1000 --group=1000 --mode=0400 /run/secrets/caplets_postgres_admin_password /prepared/admin/password - install --owner=1000 --group=1000 --mode=0400 /run/secrets/caplets_postgres_migrator_password /prepared/migrator/password - install --owner=1000 --group=1000 --mode=0400 /run/secrets/caplets_postgres_runtime_password /prepared/runtime/password + owner_uid=$(id -u node) + owner_gid=$(id -g node) + install --owner="$${owner_uid}" --group="$${owner_gid}" --mode=0400 /run/secrets/caplets_postgres_admin_password /prepared/admin/password + install --owner="$${owner_uid}" --group="$${owner_gid}" --mode=0400 /run/secrets/caplets_postgres_migrator_password /prepared/migrator/password + install --owner="$${owner_uid}" --group="$${owner_gid}" --mode=0400 /run/secrets/caplets_postgres_runtime_password /prepared/runtime/password logging: *bounded-logging caplets-postgres-migrate: