diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index c1ac3b2c96..b447d2531b 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -211,111 +211,111 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | -| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | -| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | -| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | -| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | -| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | -| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | -| `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | -| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `ported` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) — native; orchestrates the 14-container local dev stack via direct Docker/Podman subprocess spawning (no Docker Compose), mirroring Go's sequential per-container `DockerStart`. Edge Runtime container bring-up, the fresh-volume DB schema/migration/seed setup pipeline, and fresh-volume storage bucket seeding are all implemented; only the linked-project version-check suggestion is out of scope for this port (tracked follow-up). | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; only `--experimental` structured dump still delegates to Go (needs a TS DDL parser for `WriteStructuredSchemas`) | +| `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts index c555f72ff9..b1836729c6 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/api.sync.ts @@ -2,6 +2,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts"; +import { legacyStrToArr } from "../../../../shared/legacy-local-config-values.ts"; import { intToUint } from "../../../../shared/legacy-size-units.ts"; /** @@ -56,11 +57,6 @@ export interface RemoteApiConfig { readonly max_rows: number; } -/** Go `strToArr`: empty string → `[]`, else comma-split (no trimming here). */ -function strToArr(v: string): Array { - return v.length === 0 ? [] : v.split(","); -} - /** Projects the loaded `config.api` into the push subset. */ export function apiSubsetFromConfig(config: ProjectConfig): ApiSubset { const api = config.api; @@ -94,8 +90,8 @@ function applyRemoteApiConfig(local: ApiSubset, remote: RemoteApiConfig): ApiSub return { ...local, enabled: true, - schemas: strToArr(remote.db_schema).map((s) => s.trim()), - extra_search_path: strToArr(remote.db_extra_search_path).map((s) => s.trim()), + schemas: legacyStrToArr(remote.db_schema).map((s) => s.trim()), + extra_search_path: legacyStrToArr(remote.db_extra_search_path).map((s) => s.trim()), max_rows: intToUint(remote.max_rows), }; } diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index 75e6d9d484..7a884c5da2 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -13,6 +13,7 @@ import type { ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts"; +import { legacyStrToArr } from "../../../../shared/legacy-local-config-values.ts"; import { intToUint } from "../../../../shared/legacy-size-units.ts"; import { durationString, parseDuration, secondsToDurationString } from "./config-sync.duration.ts"; import type { AuthEmailContent } from "./config-sync.auth-email-content.ts"; @@ -838,11 +839,6 @@ const AUTH_FIELDS: ReadonlyArray = [ // Helpers // --------------------------------------------------------------------------- -/** Go `strToArr`: empty string → `[]`, else comma-split. */ -function strToArr(v: string): Array { - return v.length === 0 ? [] : v.split(","); -} - /** Go `cast.IntToUint`: clamp negatives to 0. */ function valOrDefault(v: T | null | undefined, def: T): T { return v == null ? def : v; @@ -1299,7 +1295,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi // Base scalar fields const siteUrl = valOrDefault(remote.site_url, ""); - const additionalRedirectUrls = strToArr(valOrDefault(remote.uri_allow_list, "")); + const additionalRedirectUrls = legacyStrToArr(valOrDefault(remote.uri_allow_list, "")); const jwtExpiry = intToUint(valOrDefault(remote.jwt_exp, 0)); const enableRefreshTokenRotation = valOrDefault(remote.refresh_token_rotation_enabled, false); const refreshTokenReuseInterval = intToUint( @@ -1322,7 +1318,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi webauthn = { rp_display_name: valOrDefault(remote.webauthn_rp_display_name, ""), rp_id: valOrDefault(remote.webauthn_rp_id, ""), - rp_origins: strToArr(valOrDefault(remote.webauthn_rp_origins, "")), + rp_origins: legacyStrToArr(valOrDefault(remote.webauthn_rp_origins, "")), }; } @@ -1786,7 +1782,7 @@ export function applyRemoteAuthConfig(local: AuthSubset, remote: RemoteAuthConfi /** Port of Go `sms.fromAuthConfig` → `envToMap`. */ function envToMap(input: string): Record { - const env = strToArr(input); + const env = legacyStrToArr(input); const result: Record = {}; for (const kv of env) { const eqIdx = kv.indexOf("="); diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 279575fbe6..6b44b14b5e 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -10,7 +10,7 @@ import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.ser import { legacyReadDbToml } from "../../shared/legacy-db-config.toml-read.ts"; import { legacyResolveDbImage } from "../../shared/legacy-db-image.ts"; import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime-image.ts"; -import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; +import { readLegacyServiceVersionOverrides } from "../../shared/legacy-service-version-overrides.ts"; import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeGoJson, encodeToml, encodeYaml } from "../../shared/legacy-go-output.encoders.ts"; @@ -20,8 +20,6 @@ import { formatServicesWarning, listLocalServiceVersions, type LocalServiceImageOverrides, - type LocalServiceVersionName, - type LocalServiceVersionOverrides, mergeRemoteServiceVersions, renderServicesTable, renderServicesWarning, @@ -185,42 +183,3 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le function formatConfigLoadError(error: unknown): string { return error instanceof Error ? error.message : String(error); } - -const LEGACY_VERSION_FILES = [ - ["auth", "gotrue-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], - ["postgrest", "rest-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], - ["storage", "storage-version"], - ["realtime", "realtime-version"], - ["studio", "studio-version"], - ["pgmeta", "pgmeta-version"], - ["analytics", "logflare-version"], - ["pooler", "pooler-version"], -] as const satisfies ReadonlyArray< - readonly [LocalServiceVersionName, string, ((majorVersion: number | undefined) => boolean)?] ->; - -const readLegacyServiceVersionOverrides = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - majorVersion: number | undefined, -) { - const paths = legacyTempPaths(path, workdir); - const versions: LocalServiceVersionOverrides = {}; - - for (const [service, fileName, shouldRead] of LEGACY_VERSION_FILES) { - if (shouldRead !== undefined && !shouldRead(majorVersion)) { - continue; - } - - const version = yield* fs.readFileString(path.join(paths.tempDir, fileName)).pipe( - Effect.map((content) => content.trim()), - Effect.orElseSucceed(() => ""), - ); - if (version.length > 0) { - versions[service] = version; - } - } - - return versions; -}); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index d9e3ac4a7a..d442412423 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -1,60 +1,248 @@ # `supabase start` +Native TypeScript port of Go's `internal/start` (+ the Postgres-container half of +`internal/db/start`). Talks directly to Docker via subprocess (`docker`/`podman`), +mirroring Go's sequential per-container `DockerStart` — it does not use Docker Compose +(the one `docker/compose` import Go has is an internal, best-effort concurrent +image-pre-pull helper this port never depends on) and it does not go through +`@supabase/stack/effect`'s orchestration model (see the CLI-1323 plan's "Critical +architectural finding" for why: that runtime is a deliberately different local-dev +product — no Kong gateway, native-binary-first, auto-allocated ports, opaque API keys — +that would break the Go-parity contract this port exists to provide). + +## Scope + +Edge Runtime bring-up, the fresh-volume DB schema/migration/seed setup pipeline, and +fresh-volume storage-bucket seeding are all now natively implemented (see below) — this +section previously listed them as out-of-scope follow-ups. + +One piece of Go's `start` remains explicitly **out of scope**, discovered during this +port's own research (not called out in Go's public docs or the original port plan) and +documented at its exact Go call site in `start.handler.ts`: + +1. **Linked-project version-check suggestion** (`internal/start/start.go:61-63`, delegates + to `internal/services.CheckVersions`) — a best-effort Management API call, made only + when a project happens to be linked _and_ the user is logged in, purely to print an + "update available" hint. Every error is silently swallowed in Go. Omitted entirely — + this port has zero Management API dependency for `start`, by design. + +### Fresh-volume DB setup (`legacyStartSetupLocalDatabase`) + +Ported: Go's `SetupLocalDatabase` → `initSchema` → +`initRealtimeJob`/`initStorageJob`/`initAuthJob` pipeline (`internal/db/start/`). Gated on +`isFreshVolume` (`legacyStartVolumeExists` on the Postgres volume, checked BEFORE the +volume is created), matching Go's `NoBackupVolume` — this same check also selects which of +`Starting database...`/`Starting database from backup...` prints to stderr immediately +before Postgres's container is created (`db/start/start.go:165-175`). Runs immediately +after Postgres's own health check passes, before "Starting containers..." prints and +before any other service starts. Opens a direct `LegacyDbConnection` session to the +host-facing Postgres address (PG<=14: execs schema/globals/API-privileges SQL over that +session; PG>=15: runs three one-shot `LegacyDockerRun` jobs instead, gated independently on +`realtime.enabled`/`storage.enabled`/`auth.enabled`). Also upserts `[db.vault]` secrets and +seeds `supabase/roles.sql`: matching Go's own print-before-read ordering +(`pkg/migration/seed.go:88`), the `Seeding globals from roles.sql...` stderr line always +prints, whether or not the file exists — a missing file is silently tolerated (no SQL runs), +any other read/exec error still fails the run. Finally runs every pending migration + seed. +A failure at any step rolls back the whole `start` run (same as any other bring-up failure). + +`legacyStartInitCurrentBranch` (writes `supabase/.branches/_current_branch` = `"main"` if +absent) is NOT part of this fresh-volume-gated pipeline — matching Go's `initCurrentBranch` +call (`db/start/start.go:189`), it runs unconditionally on every `start`, immediately after +this pipeline's gate closes (whether or not the pipeline itself ran). + +### Edge Runtime bring-up (`legacyStartEdgeRuntimeContainer`) + +Ported: Go's `serve.ServeFunctions()` call (`internal/functions/serve/`), reusing +`shared/functions/serve.ts`'s `startEdgeRuntimeContainer` core (the same one `functions +serve` uses). Gated on `edge_runtime.enabled && !--exclude edge-runtime`, in Go's real +container-start position (between ImgProxy and pg-meta). Unlike every other service, it's +a direct `docker run -d ...` (not `docker create`+`docker start`) and is health-checked via +an HTTP probe through Kong (`/functions/v1/_internal/health`), not a Docker healthcheck — +mirrors PostgREST's own probe shape. + +### Storage bucket seeding (`legacySeedBucketsRun`) + +Ported: Go's `buckets.Run(ctx, "", false, fsys)` call (`start.go:1281-1286`, +`internal/seed/buckets`). Runs only when `isFreshVolume && Storage started`, after the bulk +health check genuinely succeeds, right before the `cli_stack_started` telemetry capture. A +seeding failure rolls back the whole `start` run, same as any other post-bring-up failure. + +A second, narrower seeding path exists for the `--ignore-health-check` downgrade branch +(`start.go:1272-1277`): when the bulk health check fails but `isFreshVolume && Storage +started`, Go re-checks Storage's health in isolation and, only if Storage itself is +healthy, seeds buckets anyway before falling through to the downgrade-to-warning behavior. +Unlike the main path above, a failure on THIS path is not swallowed by +`--ignore-health-check` — it replaces the original health error, rolls back, and fails the +command (Go's `return seedErr` instead of the downgraded `return err`). + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always | +| `/supabase/.env`, `.env.local` | dotenv | always (`.env.local` skipped when `SUPABASE_ENV=test`) | +| project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | +| `auth.signing_keys_path` file | JSON | when configured | +| `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | +| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured | +| GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | +| `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | +| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | +| `/supabase/.branches/_current_branch` | text | on every start, existence check before writing (see "Files Written") | +| `/supabase/functions/**` | — | when Edge Runtime starts, and independently when Studio starts (function discovery/config resolution + Docker bind mounts, regardless of whether Edge Runtime itself is enabled) | +| `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`), written by `supabase link`; absent/unreadable resolves to no pin | +| `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always — linked-project per-service image version pins, written by `supabase link`; absent/unreadable resolves to the embedded default image | +| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| --------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | +| `/supabase/.temp/start-secrets//secret-` | varies | for Kong (`kong.yml`, TLS cert, TLS key), Postgres (`pgsodium_root.key`), and Supavisor (`pooler_tenant.exs`) — see below | +| `/supabase/.temp/start-secrets//{env,multiline-env,main}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts and bootstrap template — see below | + +Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own bootstrap +script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected +directly into each container's entrypoint (a `sh -c '... heredoc ...'` command) — +never written to the host filesystem, since none of them carries secret content. +Kong's `kong.yml`/TLS cert/TLS key, Postgres's `pgsodium_root.key`, and Supavisor's +`pooler_tenant.exs` DO carry secret content (a service-role-key-derived bearer/query +key, TLS private key material, and the DB password respectively) and are instead +written to `/supabase/.temp/start-secrets//` (directory mode +`0700`, files mode `0600`) and +bind-mounted `:ro` into the container at the exact path each container's +entrypoint/`Cmd` expects — see `container-lifecycle.ts`'s `legacyStageStartSecretFiles` +doc comment for the full rationale (CWE-214/522: keeping secret content out of the +`docker create` argv the host can see via `ps`/`/proc//cmdline`) and for why this +directory is a DETERMINISTIC, PERSISTENT path under the project's own workdir rather +than an ephemeral OS temp dir — every one of these three containers runs with +`restartPolicy: "unless-stopped"`, so the files must survive a host/Docker-daemon +restart for dockerd to successfully re-attach the bind mount. The directory is +recreated fresh (any stale contents removed first) on every `start` invocation that +reaches container creation, and is cleaned up immediately if `docker create`/`docker +start` itself fails — otherwise it is left in place for the life of the container. +Studio reads/writes SQL snippets under `/supabase/snippets/` at its own +runtime — that's Studio's behavior, not something `start` itself writes. + +Edge Runtime's own JWT/service-role-key/configured-secret env file, multiline-env +script + value files, and bootstrap `index.ts` template (`shared/functions/serve.ts`'s +`writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) are +staged the same way, under `/supabase/.temp/start-secrets//{env,multiline-env,main}/` (directory mode `0700`, files mode `0600`), +bind-mounted `:ro` into the container — a deterministic, persistent path rather than +`os.tmpdir()` (which is frequently tmpfs and gets wiped on reboot) so +`legacyCleanupStartSecrets` (see the Exit Codes/rollback section below) can reclaim +them on `stop` or a failed-start rollback, exactly like the Kong/Postgres/Supavisor +directories above. Each of the three writers removes and recreates its own +subdirectory fresh on every call (self-healing, same as the directory above), so a +shrinking env set never leaves stale files behind. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------ | --------------------- | ------------ | ------------------------- | +| GET | `/storage/v1/bucket` | Kong service-role key | — | existing bucket names/ids | +| POST/PUT | `/storage/v1/bucket[/]` | Kong service-role key | bucket props | created/updated bucket | + +Local-only: the Storage bucket-seeding step (fresh volume + Storage enabled) talks to the +LOCAL Storage service through Kong, never the Management API. See "Scope" above for the +one Go behavior (`CheckVersions`) that _would_ call the Management API and is deliberately +not implemented. ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | +| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | Resolves `LegacyCliConfig.workdir` | no | +| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | +| `DOCKER_HOST` | Read to discover the Docker daemon's own address, then re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | +| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | + +`docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — all containers started | -| `1` | malformed config | -| `1` | Docker daemon not running or connection error | -| `1` | one or more containers failed health check | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — every started container passed its health check | +| `0` | the stack was already running — shows status instead of restarting | +| `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | +| `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | +| `1` | malformed `config.toml` / `Config.Validate` failure | +| `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | +| `1` | image pull exhausted across every registry candidate | +| `1` | network, volume, container create, or container start failure (including a port conflict) — rolls back everything created so far | +| `1` | health check timeout **without** `--ignore-health-check` — rolls back | +| `1` | Postgres itself fails to start or become healthy — rolls back (no `--ignore-health-check` leniency at this stage; only the later bulk health check over the other 11 services honors the flag) | +| `1` | fresh-volume DB setup failure (schema SQL / one-shot migrate job / vault upsert / roles seed / migration-apply) — rolls back | +| `1` | fresh-volume bucket-seeding failure — rolls back | + +Rollback (`legacyRollbackStart`) tears down everything created so far by Docker label, +matching Go's `DockerRemoveAll`, and never masks the original failure — a rollback error +is logged to stderr and swallowed. `deleteVolumes` mirrors Go's `utils.NoBackupVolume` +exactly: `true` only when this run's Postgres volume was freshly created (so a failed +first-ever `start` prunes its own empty volume too), `false` otherwise (never touches a +pre-existing user's data on a failed restart). Rollback also reclaims this run's own +`/supabase/.temp/start-secrets/` directories (via +`legacyCleanupStartSecrets`, `legacy/shared/legacy-start-secrets-cleanup.ts`) once +teardown completes — the matching container names are snapshotted immediately before +teardown runs, so cleanup only ever targets containers this failed run itself created. +A later successful `stop` reclaims the same directories for a normal (non-rollback) +teardown — see `stop`'s own `SIDE_EFFECTS.md`. + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via `withLegacyCommandInstrumentation`) | `exit_code`, `duration_ms`, `flags` | +| `cli_stack_started` | once, only on a genuine bulk health-check success — never fires on the `--ignore-health-check` downgrade-to-warning path, matching Go's capture sitting after the error-return block (`start.go:1287`) | no properties | ## Output -### `--output-format text` (Go CLI compatible) +`start` has no Go `-o`/`--output` flag of its own — after orchestration (or the +already-running short-circuit, or the ignored-health-check path), it renders through the +exact same `status` value/pretty-table machinery `supabase status` uses, in all three TS +output modes. -Streams Docker pull and container start progress to stdout. Prints service URLs on success. - -### `--output-format json` / `--output json` - -Starts the local stack while capturing Go's start progress output, then emits the same JSON -object as `supabase status --output json`. +### `--output-format text` (Go CLI compatible) -Legacy `--output env|toml|yaml` follows the same path and emits the corresponding -`supabase status --output ` payload after a successful start. +- stderr (conditional, `--exclude` had invalid values): a `WARNING:` line naming the + invalid values and listing the 13 valid ones — the command still proceeds. +- stderr: already-running banner (`supabase start` in aqua) **or** the bring-up sequence: + `Starting database...` (fresh volume) / `Starting database from backup...` (existing + volume) → Postgres create+start+health-wait → `Starting containers...` → (image + pre-pull) → per-container create+start → `Waiting for health checks...` → `Started +supabase local development setup.` +- stdout: the `status` pretty table (rounded box, same renderer `supabase status` uses). +- stderr: the local-dev security notice block (bind-to-`0.0.0.0` / shared-default-keys / + no-auth-on-Studio-pgMeta-analytics warning). -### `--output-format stream-json` +### `--output-format json` / `--output-format stream-json` -Starts the local stack while capturing Go's start progress output, then emits the same JSON -object as `supabase status --output json`. +A single JSON object (or a `result` NDJSON event) carrying the same value shape +`supabase status --output json` produces. All the progress/warning/banner/security-notice +text above is suppressed in these modes — stdout stays payload-only. ## Notes -- `--exclude` / `-x` flag accepts a comma-separated list of container names to skip. -- `--ignore-health-check` suppresses unhealthy container errors and exits 0. -- `--preview` is a hidden flag to connect to a feature preview branch. -- If all containers are already running the command shows status instead. +- `--exclude`/`-x` accepts container names from the verified 13-key list (`gotrue`, + `realtime`, `storage-api`, `imgproxy`, `kong`, `mailpit`, `postgrest`, `postgres-meta`, + `studio`, `edge-runtime`, `logflare`, `vector`, `supavisor`) — `db`/`postgres` is never + excludable, matching Go. An invalid value warns and proceeds; it never fails the command. +- `--ignore-health-check` only affects the final bulk health check over the 11 + non-Postgres, non-excluded services (including Edge Runtime) — it does not relax + Postgres's own startup health wait, and it never skips rollback for a _creation_ + failure, only for a health _timeout_. On the downgrade path, if this run also freshly + created the Postgres volume and Storage started, a narrower Storage-only recheck runs + and, if Storage is healthy, buckets are seeded anyway — a failure in THAT seed step + still rolls back and fails the command despite the flag (see "Storage bucket seeding" + and the `Exit Codes` table). +- `--preview` is a hidden, parsed-but-inert flag, matching Go exactly (never read by + Go's own `start.Run`). +- The already-running check is a plain container-existence check (`docker container +inspect` on the Postgres container), not a health check — matching Go's + `AssertSupabaseDbIsRunning` naming despite what it actually verifies. diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts new file mode 100644 index 0000000000..b6de59a224 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts @@ -0,0 +1,667 @@ +/** + * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`): + * given a fully-resolved {@link LegacyStartContainerSpec} (image already + * resolved by `image-prepull.ts` — see its doc comment), sets the two + * project-identity labels, provisions this container's own named volumes, + * stages any `secretFiles` onto the host (see {@link legacyStageStartSecretFiles}), + * builds the `docker create` argv (`docker-create-args.ts`), and spawns + * `docker create` + `docker start`. + * + * Network creation (`DockerNetworkCreateIfNotExists`) is deliberately NOT part + * of this per-container function — see {@link legacyEnsureStartNetwork}'s doc + * comment for why it is hoisted to run once instead of once per container. + */ + +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Data, Effect, Stream } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "../../../shared/legacy-container-cli.ts"; +import { + legacyBindMountSpecSource, + legacyIsBindMountSource, +} from "../../../shared/legacy-docker-bind-classify.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../../../shared/legacy-docker-ids.ts"; +import { + buildLegacyStartContainerCreateArgs, + legacyApplyBitbucketStartContainerFilter, + legacyIsDockerClientEnvKey, + type LegacyStartContainerSpec, +} from "./docker-create-args.ts"; + +/** Structural element type of {@link LegacyStartContainerSpec.secretFiles} — not exported from `docker-create-args.ts`, so referenced positionally here. */ +type LegacyStartSecretFileSpec = NonNullable[number]; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Go's `composeProjectLabel` (`apps/cli-go/internal/utils/docker.go:60`, + * unexported there). This port does not integrate with docker-compose + * anywhere (an intentional architecture decision), but the label is still set + * unconditionally, matching Go's own unconditional assignment + * (`docker.go:376`) regardless of whether compose is actually in use: external + * tooling that groups/filters containers by this label (Docker Desktop's + * Compose view, `docker compose ls`, the VS Code Docker extension) would + * otherwise silently stop recognizing the local stack's containers. + * + * A same-value private constant already exists at + * `shared/functions/deploy.ts` (`dockerComposeProjectLabel`, for the unrelated + * `functions deploy` Docker Desktop extension gateway) but is neither exported + * nor in the same Docker-usage domain as `start` — not hoisted from there. + */ +const LEGACY_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; + +/** `docker network create --label ...`/`docker volume create --label ...` failed. */ +export class LegacyStartNetworkCreateError extends Data.TaggedError( + "LegacyStartNetworkCreateError", +)<{ + readonly message: string; +}> {} + +export class LegacyStartVolumeCreateError extends Data.TaggedError("LegacyStartVolumeCreateError")<{ + readonly message: string; +}> {} + +/** `docker create` failed. */ +export class LegacyStartContainerCreateError extends Data.TaggedError( + "LegacyStartContainerCreateError", +)<{ + readonly message: string; +}> {} + +/** `docker start` failed — see {@link legacyPortConflictSuggestion} for the port-already-allocated case. */ +export class LegacyStartContainerStartError extends Data.TaggedError( + "LegacyStartContainerStartError", +)<{ + readonly message: string; +}> {} + +/** Every failure {@link legacyStartContainer} itself can produce (network creation is separate, see {@link legacyEnsureStartNetwork}). */ +export type LegacyStartContainerError = + | LegacyStartVolumeCreateError + | LegacyStartContainerCreateError + | LegacyStartContainerStartError; + +export interface LegacyStartContainerOpts { + /** + * Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) by + * the caller's config-load pipeline — `DockerStart` itself performs no + * sanitization, it just reads the already-sanitized singleton + * (`docker.go:375-376`). Merged onto both {@link LEGACY_CLI_PROJECT_LABEL} + * and {@link LEGACY_COMPOSE_PROJECT_LABEL}, overwriting any value the caller + * may have already set under those keys in `spec.labels` — exactly like + * Go's unconditional map assignment. + */ + readonly projectId: string; + /** + * `os.Getenv("BITBUCKET_CLONE_DIR") != ""` — see `legacyIsBitbucketPipeline` + * (`shared/legacy-bitbucket-pipeline.ts`). Passed in rather than read here so + * this module stays a pure effect orchestrator with no direct env access, + * matching `legacyApplyBitbucketStartContainerFilter`'s own boolean-flag + * shape (`docker-create-args.ts`). + */ + readonly isBitbucketPipeline: boolean; + /** + * `LegacyCliConfig.workdir` — the project's own working directory. Used + * exclusively to root {@link legacyStageStartSecretFiles}'s deterministic, + * persistent host directory (`/supabase/.temp/start-secrets//`) + * so staged secret files survive a host/Docker-daemon restart — see that + * function's doc comment for why. + */ + readonly workdir: string; + /** + * `DockerStart`'s platform-specific `extraHosts` package var + * (`docker_linux.go`/`docker_darwin.go`/`docker_windows.go`), merged onto + * EVERY container's `HostConfig.ExtraHosts` (`docker.go:378`) — Linux-only + * (`["host.docker.internal:host-gateway"]`); empty on Docker Desktop + * platforms, which already resolve that hostname natively. Merged here + * (not per-spec) for the same reason the two project-identity labels are: + * Go applies it identically to every container this orchestrator creates. + */ + readonly extraHosts: ReadonlyArray; +} + +function collectText(stream: Stream.Stream) { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); +} + +/** + * Extracts every named-volume source from `binds` (Go's `loader.ParseVolume` + * classification loop, `docker.go:388-399`): a bind is `source:target[:mode]` + * (see `docker-create-args.ts`'s `LegacyStartContainerSpec.binds` doc comment + * and its own worked examples in `docker-create-args.unit.test.ts`), and its + * source segment is a named volume exactly when + * {@link legacyIsBindMountSource} says it is NOT a bind-mount path. No dedupe + * is applied — Go's own `sources` slice doesn't dedupe either, and + * `Docker.VolumeCreate` is idempotent for a repeated name. + */ +function legacyNamedVolumeSources(binds: ReadonlyArray): ReadonlyArray { + const sources: Array = []; + for (const bind of binds) { + const source = legacyBindMountSpecSource(bind); + if (source.length > 0 && !legacyIsBindMountSource(source)) { + sources.push(source); + } + } + return sources; +} + +/** + * Whether `docker`/`podman network create`'s stderr reports the network + * already existing — the CLI-subprocess equivalent of Go's + * `errdefs.IsConflict(err)` (`docker.go:70`), which inspects a structured + * Engine API error instead of stderr text. Docker's real message is `Error + * response from daemon: network with name already exists`; Podman's is + * worded differently (`network name already used`), hence the broader + * pattern rather than matching Docker's exact sentence. + */ +function legacyIsNetworkAlreadyExistsError(stderr: string): boolean { + return /already exists|already used/iu.test(stderr); +} + +/** Go's `portErrorPattern` (`apps/cli-go/internal/utils/docker.go:657`). */ +const LEGACY_PORT_BIND_ERROR_PATTERN = /Bind for (.*) failed: port is already allocated/; + +function legacyParsePortBindError(stderr: string): string | undefined { + return LEGACY_PORT_BIND_ERROR_PATTERN.exec(stderr)?.[1]; +} + +/** + * A scoped-down port of Go's `suggestDockerStop` + * (`apps/cli-go/internal/utils/docker.go:667-686`): Go lists every running + * container, matches the failed host port against each container's own + * published ports, and reports either `supabase stop --project-id ` (the + * port owner carries the CLI project label) or a bare `docker stop ` + * (it doesn't) — then `docker.go:425-436` appends a further "configure a + * different port" suggestion on top of that. + * + * Reproducing the lookup would mean this pure CLI-orchestration function also + * lists and inspects every other running container purely to word a hint — + * disproportionate plumbing for a suggestion string. This reproduces only the + * detection Go itself starts from (the same regex match) and folds both of + * Go's suggestion branches into one still-actionable sentence that covers + * either case without the extra lookup. + */ +function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): string { + return ( + `\nTry stopping the project or container already using ${hostPort} ` + + "(`docker ps` lists what's bound to it, or `supabase stop` for another local Supabase project), " + + `or configure a different ${serviceLabel} port in supabase/config.toml.` + ); +} + +/** + * Go's `DockerNetworkCreateIfNotExists` (`docker.go:63-77`) via `docker + * network create --label ... `, treating "already exists" as + * success. + * + * Called ONCE, up front, by the caller orchestrating a whole `start` run — + * NOT per-container the way Go's `DockerStart` calls it on every single + * invocation. Go's repeated call is a no-op after the first (the network + * already exists), so this is a pure optimization, not a behavior change: a + * `start` run's containers are exclusively created by this same code path in + * one process, never interleaved with an external network deletion, so the + * network is guaranteed to still exist for every later `legacyStartContainer` + * call in the same run. + */ +export function legacyEnsureStartNetwork( + spawner: Spawner, + networkId: string, + labels: Readonly>, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const args = [ + "network", + "create", + ...Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), + networkId, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartNetworkCreateError({ + message: `failed to create docker network: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyStartNetworkCreateError({ message: "failed to create docker network" }), + ), + ); + if (exitCode !== 0 && !legacyIsNetworkAlreadyExistsError(stderr)) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyStartNetworkCreateError({ + message: + message.length > 0 + ? `failed to create docker network: ${message}` + : "failed to create docker network", + }), + ); + } + }), + ); +} + +/** + * Go's per-source-name `Docker.VolumeCreate` call (`docker.go:407-415`) via + * `docker volume create --label ...`. Unlike network creation, Go applies no + * "already exists" tolerance here — `VolumeCreate` is already idempotent for a + * repeated name with matching options, so any non-zero exit is a real failure. + */ +export function legacyEnsureStartVolume( + spawner: Spawner, + name: string, + labels: Readonly>, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const args = [ + "volume", + "create", + ...Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), + name, + ]; + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartVolumeCreateError({ + message: `failed to create volume: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyStartVolumeCreateError({ message: "failed to create volume" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyStartVolumeCreateError({ + message: + message.length > 0 + ? `failed to create volume: ${message}` + : "failed to create volume", + }), + ); + } + }), + ); +} + +/** `docker volume inspect` failed to spawn at all (no docker/podman binary). */ +export class LegacyStartVolumeInspectError extends Data.TaggedError( + "LegacyStartVolumeInspectError", +)<{ + readonly message: string; +}> {} + +/** Docker's/Podman's "no such volume" stderr shape for `volume inspect`. */ +function isVolumeNotFoundMessage(message: string): boolean { + return /no such volume/iu.test(message); +} + +/** + * Go's pre-create existence check (`_, err := utils.Docker.VolumeInspect(ctx, + * utils.DbId); utils.NoBackupVolume = errdefs.IsNotFound(err)`, + * `apps/cli-go/internal/db/start/start.go:165-167`), run BEFORE the volume is + * created — `docker volume create` is idempotent, so creating first would lose + * whether the volume already existed. `docker volume inspect ` exits 0 + * when the volume exists; a confirmed "no such volume" resolves to `false`, + * matching Go's `errdefs.IsNotFound`. Any OTHER inspect failure (permission + * denied, daemon unreachable, …) resolves to `true` instead — Go's + * `errdefs.IsNotFound(err)` is `false` for any error that isn't specifically a + * not-found, so Go always defaults to treating the volume as pre-existing + * (protected from rollback's `volume prune`) unless it can positively confirm + * otherwise; collapsing every non-zero exit into "doesn't exist" would let an + * ambiguous inspect failure on a stack with real prior data get pruned by + * {@link legacyRollbackStart} after any later failure — a data-loss + * regression Go's own gate doesn't have. Only a spawn failure (neither + * `docker` nor `podman` on `PATH`) is a real error here. + * + * A separate, additional export — NOT called from {@link legacyEnsureStartVolume} + * itself, whose existing idempotent-create behavior must not change. The caller + * orchestrating a `start` run checks this BEFORE creating the volume, to gate the + * `SetupLocalDatabase`-equivalent pipeline and bucket seeding on "was this a + * fresh volume", matching Go's exact check-before-create ordering + * (`internal/db/start/start.go:165-184`). + */ +export function legacyStartVolumeExists( + spawner: Spawner, + name: string, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["volume", "inspect", name], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartVolumeInspectError({ + message: `failed to inspect volume: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => new LegacyStartVolumeInspectError({ message: "failed to inspect volume" }), + ), + ); + if (exitCode === 0) return true; + return !isVolumeNotFoundMessage(stderr); + }), + ); +} + +function legacyDockerCreateContainer( + spawner: Spawner, + args: ReadonlyArray, + env: Readonly>, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so + // secrets never appear in argv/`ps`/`/proc//cmdline` (CWE-214/209) — Docker then + // resolves each key's value from THIS spawned process's own environment. `extendEnv: + // true` keeps the rest of the parent's env (PATH, the real DOCKER_HOST, …) so the docker + // CLI invocation itself still behaves correctly; `env` supplies the actual secret values. + // Matches the same pattern already used for `docker run` (`legacy-docker-run.layer.ts`) + // and image resolution (`legacy-docker-image-resolve.ts`). + // + // Callers must have already stripped `legacyIsDockerClientEnvKey` keys (e.g. a + // container-facing `DOCKER_HOST`, set by Vector's spec for a tcp/npipe daemon host) from + // `env` before calling this function — those are emitted inline as `-e KEY=value` by + // `buildLegacyStartContainerCreateArgs` instead, since `extendEnv: true` merges `env` INTO + // this spawned process's own environment (per Effect's `ChildProcess` semantics, + // prioritizing `env`'s values), and that same environment is what the `docker`/`podman` + // CLI client itself reads `DOCKER_HOST` from to pick which daemon to talk to. Letting a + // container-facing `DOCKER_HOST` leak in here would hijack this `docker create` call's own + // daemon target before the container even exists. + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + extendEnv: true, + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartContainerCreateError({ + message: `failed to create docker container: ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectText(child.stdout), + collectText(child.stderr), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new LegacyStartContainerCreateError({ message: "failed to create docker container" }), + ), + ); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + new LegacyStartContainerCreateError({ + message: + message.length > 0 + ? `failed to create docker container: ${message}` + : "failed to create docker container", + }), + ); + } + return stdout.trim(); + }), + ); +} + +function legacyDockerStartContainer( + spawner: Spawner, + containerId: string, + spec: LegacyStartContainerSpec, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["start", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError( + (cause) => + new LegacyStartContainerStartError({ + message: `failed to start docker container "${spec.containerName}": ${legacyDescribeContainerCliFailure(cause)}`, + }), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + () => + new LegacyStartContainerStartError({ + message: `failed to start docker container "${spec.containerName}"`, + }), + ), + ); + if (exitCode !== 0) { + const trimmed = stderr.trim(); + const base = `failed to start docker container "${spec.containerName}": ${ + trimmed.length > 0 ? trimmed : `exit ${exitCode}` + }`; + const hostPort = legacyParsePortBindError(trimmed); + if (hostPort === undefined) { + return yield* Effect.fail(new LegacyStartContainerStartError({ message: base })); + } + const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; + return yield* Effect.fail( + new LegacyStartContainerStartError({ + message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, + }), + ); + } + }), + ); +} + +/** + * Writes each {@link LegacyStartContainerSpec.secretFiles} entry's `content` + * to a HOST directory scoped to this container, at the DETERMINISTIC path + * `/supabase/.temp/start-secrets//` (matching this + * codebase's existing `/supabase/.temp/` convention for CLI-owned + * scratch state — see `legacy-linked-project-cache.layer.ts`), directory mode + * `0700` and one file per entry at mode `0600` — both owner-only, so no other + * local user on the host can even list the staged file names/count (let + * alone read their contents) while they exist — then returns the + * `::ro` + * bind for each. Mirrors this same session's `-e KEY`-only env fix + * (`legacyDockerCreateContainer`'s doc comment) for entrypoint/`Cmd`-bound + * secret content instead of env values: the file's HOST path is the only + * thing that ever reaches `docker create`'s argv, never the secret `content` + * itself (CWE-214/522). + * + * Deliberately NOT an ephemeral `os.tmpdir()` directory (`fs.mkdtemp`, this + * function's original implementation): every container that uses this + * mechanism (Kong, Postgres, Supavisor) runs with `restartPolicy: + * "unless-stopped"`, so dockerd re-attaches its bind mounts on its own after + * a host/daemon restart — by which point `supabase start`'s own process (and + * any `os.tmpdir()` directory it made, which is frequently tmpfs on Linux and + * wiped on reboot regardless) is long gone. Re-attaching a bind mount whose + * host source no longer exists either fails outright or (with the legacy `-v + * host:container` syntax) silently recreates an empty directory there — + * either way silently dropping the secret content (e.g. Postgres's + * `pgsodium_root.key`) until the user manually reruns `stop` + `start`. A + * path rooted in the project's own working directory survives the restart, + * exactly like Go's own heredoc'd-into-`Entrypoint`/`Cmd` approach does (via + * dockerd's own persisted container metadata) — without reintroducing the + * argv-exposure problem that approach has. + * + * Self-healing: any pre-existing directory at this path is removed FIRST, + * before writing fresh files, on every call — so a config change that + * shrinks or removes `secretFiles` between `start` invocations never leaves a + * stale file behind, and no orphaned directories accumulate across restarts. + * `legacyStartContainer` never resolves this for a container while an + * earlier instance of that same container might still be reading from it — + * see that function's doc comment. + */ +function legacyStageStartSecretFiles( + secretFiles: ReadonlyArray, + containerName: string, + workdir: string, +): Effect.Effect< + { readonly binds: ReadonlyArray; readonly cleanup: () => Promise }, + LegacyStartContainerCreateError +> { + const dir = join(workdir, "supabase", ".temp", "start-secrets", containerName); + return Effect.tryPromise({ + try: async () => { + await rm(dir, { recursive: true, force: true }); + if (secretFiles.length === 0) { + return { binds: [], cleanup: () => Promise.resolve() }; + } + try { + await mkdir(dir, { recursive: true, mode: 0o700 }); + const binds = await Promise.all( + secretFiles.map(async (secretFile, index) => { + const hostPath = join(dir, `secret-${index}`); + await writeFile(hostPath, secretFile.content, { mode: 0o600 }); + return `${hostPath}:${secretFile.containerPath}:ro`; + }), + ); + return { binds, cleanup: () => rm(dir, { recursive: true, force: true }) }; + } catch (cause) { + await rm(dir, { recursive: true, force: true }); + throw cause; + } + }, + catch: (cause) => + new LegacyStartContainerCreateError({ + message: `failed to create docker container: failed to stage container secret files: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); +} + +/** + * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`), + * minus image resolution (already done by `image-prepull.ts`) and network + * creation (hoisted, see {@link legacyEnsureStartNetwork}): + * + * 1. Merge the two project-identity labels onto `spec.labels`. + * 2. Provision this container's own named volumes (skipped entirely under + * Bitbucket Pipelines, matching Go). + * 3. Apply the Bitbucket named-volume-bind / security-opt filter + * (`legacyApplyBitbucketStartContainerFilter`, already ported). + * 4. Stage any `secretFiles` onto the host and append their bind mounts + * (`legacyStageStartSecretFiles`) — a TS-port-only step with no Go + * equivalent, see `docker-create-args.ts`'s `secretFiles` doc comment. + * 5. `docker create` then `docker start`. The staged secret files are left in + * place on success — they must persist for the container's whole + * lifetime so a `restartPolicy: "unless-stopped"` restart (dockerd + * re-attaching bind mounts after a host reboot, long after this process + * has exited) can still read them; see `legacyStageStartSecretFiles`'s doc + * comment. Only cleaned up (best-effort, `Effect.tapError`) when `docker + * create`/`docker start` itself FAILS and the container never successfully + * starts — nothing is depending on the files at that point. Once the + * container is actually torn down (a failed-start rollback, or a later + * `stop`), `legacyCleanupStartSecrets` (`legacy-start-secrets-cleanup.ts`) + * reclaims the staged directory then instead. + * + * Resolves to the created container's id/name on success. + */ +export function legacyStartContainer( + spawner: Spawner, + spec: LegacyStartContainerSpec, + opts: LegacyStartContainerOpts, +): Effect.Effect { + return Effect.gen(function* () { + const labels: Record = { + ...spec.labels, + [LEGACY_CLI_PROJECT_LABEL]: opts.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: opts.projectId, + }; + const labeledSpec: LegacyStartContainerSpec = { + ...spec, + labels, + extraHosts: [...(spec.extraHosts ?? []), ...opts.extraHosts], + }; + + if (!opts.isBitbucketPipeline) { + for (const name of legacyNamedVolumeSources(labeledSpec.binds)) { + yield* legacyEnsureStartVolume(spawner, name, labels); + } + } + + const finalSpec = legacyApplyBitbucketStartContainerFilter( + labeledSpec, + opts.isBitbucketPipeline, + ); + + const { binds: secretBinds, cleanup: cleanupSecretFiles } = yield* legacyStageStartSecretFiles( + finalSpec.secretFiles ?? [], + finalSpec.containerName, + opts.workdir, + ); + const specWithSecretBinds: LegacyStartContainerSpec = + secretBinds.length === 0 + ? finalSpec + : { ...finalSpec, binds: [...finalSpec.binds, ...secretBinds] }; + + return yield* Effect.gen(function* () { + const createArgs = buildLegacyStartContainerCreateArgs(specWithSecretBinds); + // `legacyIsDockerClientEnvKey` keys (e.g. Vector's container-facing `DOCKER_HOST`) are + // already emitted inline as `-e KEY=value` by `buildLegacyStartContainerCreateArgs` above — + // see `legacyDockerCreateContainer`'s doc comment for why they must not also reach the + // spawned `docker create` process's own environment. + const createProcessEnv = Object.fromEntries( + Object.entries(specWithSecretBinds.env).filter(([key]) => !legacyIsDockerClientEnvKey(key)), + ); + const containerId = yield* legacyDockerCreateContainer(spawner, createArgs, createProcessEnv); + yield* legacyDockerStartContainer(spawner, containerId, specWithSecretBinds); + return containerId; + }).pipe( + Effect.tapError(() => + Effect.promise(cleanupSecretFiles).pipe(Effect.catchCause(() => Effect.void)), + ), + ); + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts new file mode 100644 index 0000000000..dea221e264 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts @@ -0,0 +1,674 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, beforeEach } from "vitest"; + +import { + LegacyStartContainerCreateError, + LegacyStartContainerStartError, + LegacyStartNetworkCreateError, + LegacyStartVolumeCreateError, + LegacyStartVolumeInspectError, + legacyEnsureStartNetwork, + legacyEnsureStartVolume, + legacyStartContainer, + legacyStartVolumeExists, +} from "./container-lifecycle.ts"; +import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "supabase-legacy-start-container-lifecycle-")); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +/** Matches the standing `mockSpawner` shape used across `legacy-docker-*.unit.test.ts` files, generalized to a per-call handler for multi-step orchestration (volume create -> container create -> container start). */ +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const encoder = new TextEncoder(); + const spawned: Array> = []; + const spawnedOptions: Array<{ + readonly args: ReadonlyArray; + readonly env: Record | undefined; + readonly extendEnv: boolean | undefined; + }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + if (command._tag === "StandardCommand") { + spawnedOptions.push({ + args, + env: command.options.env, + extendEnv: command.options.extendEnv, + }); + } + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + get spawnedOptions() { + return spawnedOptions; + }, + }; +} + +const baseSpec: LegacyStartContainerSpec = { + image: "public.ecr.aws/supabase/postgres:15", + containerName: "supabase_db_proj", + env: {}, + binds: ["supabase_db_proj:/var/lib/postgresql/data", "/repo/backup.sql:/etc/backup.sql:ro"], + securityOpt: ["label:disable"], + networkId: "supabase_network_proj", + networkAliases: ["db"], + labels: {}, +}; + +function alwaysSucceed(stdout = "container-id-123\n") { + return mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout }; + return { exitCode: 0 }; + }); +} + +describe("legacyStartContainer", () => { + it.live( + "merges project + compose labels, provisions named volumes, then creates and starts", + () => { + const mock = alwaysSucceed(); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("container-id-123"); + + const volumeCreate = mock.spawned.find( + (args) => args[0] === "volume" && args[1] === "create", + ); + expect(volumeCreate).toEqual([ + "volume", + "create", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "supabase_db_proj", + ]); + + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create).toContain("--label"); + expect(create).toContain("com.supabase.cli.project=proj"); + expect(create).toContain("com.docker.compose.project=proj"); + // Both the named-volume bind and the plain bind mount survive outside Bitbucket. + expect(create).toContain("supabase_db_proj:/var/lib/postgresql/data"); + expect(create).toContain("/repo/backup.sql:/etc/backup.sql:ro"); + expect(create).toContain("--security-opt"); + + const start = mock.spawned.find((args) => args[0] === "start"); + expect(start).toEqual(["start", "container-id-123"]); + + // Order matters: the volume must exist before `docker create` references it. + expect(mock.spawned.map((args) => args[0])).toEqual(["volume", "create", "start"]); + }), + ); + }, + ); + + it.live( + "passes the spec's env values through the spawned process's own environment, extending it", + () => { + // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so + // secrets never appear in argv — Docker then resolves each key's value from the + // spawned `docker create` process's own environment. If `env`/`extendEnv` are ever + // dropped from the spawn call again, every `-e KEY` flag silently resolves to nothing + // and every container starts with none of its configured environment. + const mock = alwaysSucceed(); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + env: { POSTGRES_PASSWORD: "s3cret", JWT_SECRET: "super-secret-value" }, + }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + const create = mock.spawnedOptions.find((entry) => entry.args[0] === "create"); + expect(create?.env).toEqual({ + POSTGRES_PASSWORD: "s3cret", + JWT_SECRET: "super-secret-value", + }); + expect(create?.extendEnv).toBe(true); + }), + ); + }, + ); + + it.live( + "excludes DOCKER_HOST from the spawned docker create process's own env, even though it's in spec.env (Vector's tcp/npipe daemon host)", + () => { + // `env`/`extendEnv: true` merge `spec.env` INTO the spawned `docker create` process's own + // environment (see the previous test) — but `DOCKER_HOST` configures which daemon the + // `docker`/`podman` CLI CLIENT itself talks to, not a container env var read via `-e KEY`. + // Letting a container-facing `DOCKER_HOST` (e.g. Vector's `http://host.docker.internal:...` + // for a tcp/npipe daemon host) leak into the spawned process's own env would hijack which + // daemon this `docker create` call itself targets, before the container even exists. + const mock = alwaysSucceed(); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + env: { DOCKER_HOST: "http://host.docker.internal:2375", API_KEY: "s3cret" }, + }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + const create = mock.spawnedOptions.find((entry) => entry.args[0] === "create"); + expect(create?.env).toEqual({ API_KEY: "s3cret" }); + expect(create?.args).toContain("DOCKER_HOST=http://host.docker.internal:2375"); + }), + ); + }, + ); + + it.live( + "skips volume creation and drops the named-volume bind + security-opt under Bitbucket Pipelines", + () => { + const mock = alwaysSucceed(); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: true, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + expect(mock.spawned.some((args) => args[0] === "volume")).toBe(false); + + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create).not.toContain("supabase_db_proj:/var/lib/postgresql/data"); + expect(create).toContain("/repo/backup.sql:/etc/backup.sql:ro"); + expect(create).not.toContain("--security-opt"); + }), + ); + }, + ); + + it.live("fails with LegacyStartVolumeCreateError before ever creating the container", () => { + const mock = mockSpawner((args) => { + if (args[0] === "volume") return { exitCode: 1, stderr: "no space left on device\n" }; + return { exitCode: 0, stdout: "should-not-be-created\n" }; + }); + return legacyStartContainer(mock.spawner, baseSpec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error.message).toBe("failed to create volume: no space left on device"); + expect(mock.spawned.some((args) => args[0] === "create")).toBe(false); + }), + ); + }); + + it.live("fails with LegacyStartContainerCreateError on a `docker create` non-zero exit", () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 1, stderr: "no such image\n" }; + return { exitCode: 0 }; + }); + const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error.message).toBe("failed to create docker container: no such image"); + expect(mock.spawned.some((args) => args[0] === "start")).toBe(false); + }), + ); + }); + + it.live("fails with LegacyStartContainerStartError, unmodified, on a plain start failure", () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout: "abc\n" }; + if (args[0] === "start") return { exitCode: 1, stderr: "container is already stopped\n" }; + return { exitCode: 0 }; + }); + const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error.message).toBe( + 'failed to start docker container "supabase_db_proj": container is already stopped', + ); + }), + ); + }); + + it.live( + "appends a port-conflict suggestion, naming the container's first network alias, on a port-already-allocated failure", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout: "abc\n" }; + if (args[0] === "start") { + return { + exitCode: 1, + stderr: + "Error response from daemon: driver failed programming external connectivity on endpoint supabase_db_proj: Bind for 0.0.0.0:5432 failed: port is already allocated\n", + }; + } + return { exitCode: 0 }; + }); + const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error.message).toContain('failed to start docker container "supabase_db_proj"'); + expect(error.message).toContain("0.0.0.0:5432"); + expect(error.message).toContain("db port in supabase/config.toml"); + }), + ); + }, + ); +}); + +describe("legacyStartContainer secretFiles", () => { + it.live( + "stages a secretFile as a mode-0600 HOST file under a mode-0700 deterministic, per-container directory, bind-mounts it read-only at the exact containerPath, keeps the raw content out of argv, and PERSISTS the file after a successful start so a `restartPolicy: unless-stopped` container can survive a host/daemon restart (CWE-214/522)", + () => { + let hostPath: string | undefined; + let modeAtCreateTime: number | undefined; + let dirModeAtCreateTime: number | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + if (bind !== undefined) { + hostPath = bind.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + modeAtCreateTime = statSync(hostPath).mode & 0o777; + dirModeAtCreateTime = statSync(dirname(hostPath)).mode & 0o777; + } + return { exitCode: 0, stdout: "container-id-789\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("container-id-789"); + + const create = mock.spawned.find((a) => a[0] === "create"); + expect(create?.some((a) => a.includes("super-secret-content"))).toBe(false); + + expect(hostPath).toBeDefined(); + expect(modeAtCreateTime).toBe(0o600); + expect(dirModeAtCreateTime).toBe(0o700); + expect(create).toContain(`${hostPath}:/etc/kong/kong.yml:ro`); + + // Deterministic — rooted in the project's own workdir, not an OS temp dir, and + // scoped by container name so sibling services never collide. + expect(hostPath).toBe( + join(workdir, "supabase", ".temp", "start-secrets", spec.containerName, "secret-0"), + ); + + // The staged file is left in place after a successful start: it must survive for + // the container's whole lifetime so a `restartPolicy: unless-stopped` restart (e.g. + // dockerd re-attaching this bind mount after a host reboot, long after this process + // has exited) can still read it. + expect(existsSync(hostPath ?? "")).toBe(true); + }), + ); + }, + ); + + it.live( + "removes any pre-existing directory at the deterministic path before writing fresh files (self-healing across config changes)", + () => { + const dir = join(workdir, "supabase", ".temp", "start-secrets", baseSpec.containerName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "stale-leftover-from-a-previous-config"), "old-content"); + + const mock = alwaysSucceed("container-id-stale\n"); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "fresh-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map(() => { + expect(readdirSync(dir)).toEqual(["secret-0"]); + expect(readFileSync(join(dir, "secret-0"), "utf8")).toBe("fresh-content"); + }), + ); + }, + ); + + it.live("cleans up the staged file even when `docker create` fails", () => { + let hostPath: string | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + hostPath = bind?.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + return { exitCode: 1, stderr: "no such image\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(hostPath).toBeDefined(); + expect(existsSync(hostPath ?? "")).toBe(false); + }), + ); + }); + + it.live( + "cleans up the staged file when `docker create` succeeds but `docker start` fails", + () => { + let hostPath: string | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + const bind = args.find((a) => a.endsWith(":/etc/kong/kong.yml:ro")); + hostPath = bind?.slice(0, bind.length - ":/etc/kong/kong.yml:ro".length); + return { exitCode: 0, stdout: "container-id-abc\n" }; + } + if (args[0] === "start") { + return { exitCode: 1, stderr: "container is already stopped\n" }; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(hostPath).toBeDefined(); + // The container never successfully started, so nothing depends on the file surviving. + expect(existsSync(hostPath ?? "")).toBe(false); + }), + ); + }, + ); + + it.live( + "maps a staging write failure to LegacyStartContainerCreateError, without ever invoking `docker create`", + () => { + const dir = join(workdir, "supabase", ".temp", "start-secrets", baseSpec.containerName); + // `dir` itself doesn't exist yet, so the self-healing `rm(dir, ...)` up front is a no-op — + // but its PARENT ("start-secrets") is a plain file instead of a directory, which forces + // `mkdir(dir, { recursive: true })` to fail with ENOTDIR. This exercises the staging + // try/catch's failure branch, which has no other way to fail deterministically from a + // unit test. + mkdirSync(dirname(dirname(dir)), { recursive: true }); + writeFileSync(dirname(dir), "not a directory"); + + const mock = mockSpawner(() => ({ exitCode: 0, stdout: "should-not-be-created\n" })); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return legacyStartContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error.message).toMatch( + /^failed to create docker container: failed to stage container secret files: /, + ); + expect(mock.spawned.some((args) => args[0] === "create")).toBe(false); + // `mkdir` never got far enough to create anything at the per-container path — the + // inner catch's own `rm(dir, ...)` cleanup call is a no-op here, but still runs. + expect(existsSync(dir)).toBe(false); + }), + ); + }, + ); +}); + +describe("legacyEnsureStartNetwork", () => { + it.live("creates the network with labels", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", { + "com.supabase.cli.project": "proj", + "com.docker.compose.project": "proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([ + [ + "network", + "create", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "supabase_network_proj", + ], + ]); + }), + ); + }); + + it.live("treats an already-exists failure as success", () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: + "Error response from daemon: network with name supabase_network_proj already exists\n", + })); + return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + Effect.map(() => { + // Just needs to not fail — no return value to assert on. + }), + ); + }); + + it.live("fails with LegacyStartNetworkCreateError on any other failure", () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartNetworkCreateError); + expect(error.message).toBe("failed to create docker network: permission denied"); + }), + ); + }); +}); + +describe("legacyEnsureStartVolume", () => { + it.live("creates the named volume with labels", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", { + "com.supabase.cli.project": "proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([ + ["volume", "create", "--label", "com.supabase.cli.project=proj", "supabase_db_proj"], + ]); + }), + ); + }); + + it.live("fails on any non-zero exit, with no already-exists tolerance", () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: + "a volume named supabase_db_proj already exists but was not created for the current specification\n", + })); + return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error.message).toBe( + "failed to create volume: a volume named supabase_db_proj already exists but was not created for the current specification", + ); + }), + ); + }); +}); + +describe("legacyStartVolumeExists", () => { + it.live("resolves true when `docker volume inspect` exits 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0, stdout: "[]\n" })); + return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(true); + expect(mock.spawned).toEqual([["volume", "inspect", "supabase_db_proj"]]); + }), + ); + }); + + it.live('resolves false on a "no such volume" non-zero exit', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such volume: supabase_db_proj\n", + })); + return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(false); + }), + ); + }); + + it.live( + "resolves true (protected, not fresh) on an ambiguous inspect failure, matching Go's IsNotFound gate", + () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(true); + }), + ); + }, + ); + + it.live("fails with LegacyStartVolumeInspectError when no runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + return legacyStartVolumeExists(spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartVolumeInspectError); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.ts new file mode 100644 index 0000000000..c9722c8cdd --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.ts @@ -0,0 +1,650 @@ +/** + * Post-Postgres-health local database setup pipeline — a strict 1:1 port of Go's + * `SetupLocalDatabase` (`apps/cli-go/internal/db/start/start.go:359-381`), run once + * the `db` container's healthcheck passes on a FRESH volume (Go's `NoBackupVolume` + * gate, `start.go:184` — the caller decides whether to invoke this at all; see + * `legacyStartVolumeExists` in `./container-lifecycle.ts`). The single exported + * entry point, {@link legacyStartSetupLocalDatabase}, runs the exact Go call chain + * in order: + * + * 1. **`initSchema`** (`start.go:243-266`) — prints `Initialising schema...`, then + * branches on `db.major_version`: + * - **PG <= 14**: execs {@link LEGACY_START_DB_GLOBALS_SQL} (`utils.GlobalsSql`) + * then either {@link LEGACY_START_DB_INITIAL_SCHEMA_13_SQL} or + * {@link LEGACY_START_DB_INITIAL_SCHEMA_14_SQL} (`InitSchema14`, `start.go:256-266`, + * keyed on `major_version == 13`), each via `legacyExecSqlFile` against a temp file. + * - **PG >= 15** (`initSchema15`, `start.go:334-357`): runs up to three one-shot, + * foreground Docker jobs (`utils.DockerRunJob` = `DockerRunOnceWithStream`, a + * run-to-completion container on the SAME Docker network as `db` — Go's + * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, + * `docker.go:379-383`), each gated on its own service's `enabled` flag and none + * of which touch `conn` directly: + * - `initRealtimeJob` (`start.go:268-295`) — reuses + * `../services/realtime.service.ts`'s `legacyBuildRealtimeEnv`, which builds + * the byte-identical env-var literal Go's own `initRealtimeJob` embeds + * verbatim (both are the same Go `Env` list, just addressed from two call + * sites: the long-running container and this one-shot job). + * - `initStorageJob` (`start.go:297-317`) — a DELIBERATELY SMALLER, differently- + * keyed env set than the long-running Storage container's own + * `storage.service.ts` builder (`PGRST_JWT_SECRET` not `AUTH_JWT_SECRET`, + * `STORAGE_FILE_BACKEND_PATH` not `FILE_STORAGE_BACKEND_PATH`, `REGION` not + * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. + * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from + * `gotrue.service.ts`'s full container builder. + * 2. **`ApplyApiPrivileges`** (`start.go:414-435`) — tri-state on + * `api.auto_expose_new_tables`: `true` is a no-op (keep the bundled initial-schema + * grants); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL} + * (Go's inline `RevokeDefaultDataApiPrivilegesSql` constant, `start.go:405-412`) + * via a temp file, same as the schema SQL above. + * 3. **Vault upsert** (`start.go:390-393`) — `legacyUpsertVaultSecrets`, run BEFORE + * the custom-roles seed "so roles.sql can reference them" (Go's own comment). + * 4. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — + * prints "Seeding globals from roles.sql..." UNCONDITIONALLY, BEFORE checking + * whether `supabase/roles.sql` even exists (Go's `SeedGlobals` prints first, + * then attempts the read), then execs the file via `legacyExecSqlFile` only when + * it's actually present. A missing file is tolerated (Go's `errors.Is(err, + * os.ErrNotExist)` check, reproduced here as an existence check ahead of the read + * rather than a caught not-found error — see the call site's own comment for why); + * any other read/exec error propagates. + * 5. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported + * `legacyMigrateAndSeed`) with `version: ""` — every pending migration, matching + * `SetupLocalDatabase`'s own call in the `start` context. + * + * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ + * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though + * it's exported from this module ({@link legacyStartInitCurrentBranch}): in Go it's + * called by `StartDatabase` (the caller of `SetupLocalDatabase`) UNCONDITIONALLY, + * regardless of `NoBackupVolume` (`start.go:184-189`) — unlike everything above, + * which only runs on a fresh volume. `start.handler.ts` calls it directly, outside + * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}. + * + * Go's best-effort `pgcache.TryCacheMigrationsCatalog` warning (`start.go:371-379`) + * is intentionally NOT ported — same accepted, documented divergence as + * `db/reset/reset.handler.ts`'s identical comment (no output impact either way). + * + * This module also duplicates ONE config-load pass: `legacyCheckDbToml` is called + * internally (not threaded in from the caller) to resolve `[db.vault]`, `[db.seed]`, + * `db.migrations.enabled`, and the effective `api.auto_expose_new_tables` tri-state — + * the same accepted duplication `db start`'s own handler already takes + * independently of the top-level `start` command's own config resolution (see + * `commands/db/start/start.handler.ts:40`). + */ + +import type { ProjectConfig } from "@supabase/config"; +import { Data, Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import { legacyMigrateAndSeed } from "../../../shared/legacy-migrate-and-seed.ts"; +import { + LegacyMigrationApplyError, + legacyExecSqlFile, +} from "../../../shared/legacy-migration-apply.ts"; +import type { LegacyMigrationSeedError } from "../../../shared/legacy-seed.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; +import { + LegacyMigrationVaultError, + legacyUpsertVaultSecrets, +} from "../../../shared/legacy-vault.ts"; +import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "../services/realtime.service.ts"; +import { LEGACY_START_DB_GLOBALS_SQL } from "../templates/db-globals.sql.ts"; +import { LEGACY_START_DB_INITIAL_SCHEMA_13_SQL } from "../templates/db-initial-schema-13.sql.ts"; +import { LEGACY_START_DB_INITIAL_SCHEMA_14_SQL } from "../templates/db-initial-schema-14.sql.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "./internal-db-connection.ts"; + +/** + * Go's inline `RevokeDefaultDataApiPrivilegesSql` constant (`start.go:405-412`) — + * NOT a `//go:embed` file (unlike the three large SQL templates), so transcribed + * directly here rather than as a sibling `templates/*.sql.ts` module. + */ +const LEGACY_START_REVOKE_API_PRIVILEGES_SQL = ` +alter default privileges for role postgres in schema public + revoke select, insert, update, delete on tables from anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke usage, select on sequences from anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from anon, authenticated, service_role; +`; + +/** + * A SQL exec (schema/globals/API-privileges) or one-shot service-migration Docker + * job failed, or the scratch temp directory/file could not be created. The Docker + * job branch's message mirrors Go's `DockerRunOnceWithStream` failure shape + * (`errors.Errorf("error running container: %w", err)`, `apps/cli-go/internal/ + * utils/docker.go:469-487,559-591` — Go discards the container's own stdout/stderr + * outside `--debug`, so only the exit code is meaningful here too). + */ +export class LegacyStartDbSetupError extends Data.TaggedError("LegacyStartDbSetupError")<{ + readonly message: string; +}> {} + +/** Every failure {@link legacyStartSetupLocalDatabase} can produce. */ +export type LegacyStartSetupLocalDatabaseError = + | LegacyDbConfigLoadError + | LegacyStartDbSetupError + | LegacyMigrationVaultError + | LegacyMigrationApplyError + | LegacyMigrationSeedError; + +/** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ +export interface LegacyStartDbSetupImages { + /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `ProjectConfig` schema — `toml:"-"`). */ + readonly realtime: string; + /** `utils.Config.Storage.Image`, ditto. */ + readonly storage: string; + /** `utils.Config.Auth.Image`, ditto. */ + readonly auth: string; +} + +/** Input to {@link legacyStartSetupLocalDatabase}. */ +export interface LegacyStartSetupLocalDatabaseInput { + /** + * An already-open session to the local Postgres database, dialed the same way + * Go's `ConnectLocalPostgres(ctx, pgconn.Config{})` does (`internal/utils/ + * connect.go:144-167`): the HOST-facing address (`legacyGetHostname()` + + * `db.port`, user `postgres`, `isLocal: true`) — the SAME shape `legacy-db- + * config.layer.ts`'s own `--local` branch already dials (`legacy-db-config. + * layer.ts:518-529`). This is deliberately NOT the internal Docker-network `db` + * container address the PG15+ one-shot jobs below connect through (see + * `networkId`/`projectId`) — the two addressing schemes are independent, exactly + * like Go's `conn` (host-facing) vs. `host` parameter (`utils.DbId`) in + * `SetupDatabase(ctx, conn, utils.DbId, w, fsys)`. + */ + readonly session: LegacyDbSession; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + /** The Supabase project root (parent of `supabase/`). */ + readonly workdir: string; + /** The caller's already-resolved, effective config (env overrides already applied). */ + readonly config: ProjectConfig; + /** `db.major_version` (13-17) — Go's `utils.Config.Db.MajorVersion`, resolved by the caller once, ahead of the `db` container's own image tag selection. */ + readonly majorVersion: number; + /** Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) — derives the `db` container's internal Docker name for the PG15+ one-shot jobs (`legacyServiceContainerName("db", projectId)`, Go's `utils.DbId`). */ + readonly projectId: string; + /** The `start` run's Docker network id (Go's `utils.NetId` or the `--network-id` override) — every PG15+ one-shot job joins it, matching `DockerStart`'s own default (`docker.go:379-383`). */ + readonly networkId: string; + /** `LegacyLocalConfigValues.dbUrl` — reused (not recomputed) to derive the internal DB password via `legacyStartInternalDbPassword`, matching every other `start/services/*.service.ts` builder. */ + readonly dbUrl: string; + /** `LegacyLocalConfigValues.jwtSecret`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string (only read when `realtime.enabled`) — already built by the caller, not recomputed here. */ + readonly jwks: string; + /** `LegacyLocalConfigValues.apiUrl` — the auth job's `API_EXTERNAL_URL` falls back to this, `/auth/v1`-suffixed, only when {@link authExternalUrl} is unset. */ + readonly apiUrl: string; + /** + * Raw `auth.external_url` (already `SUPABASE_AUTH_EXTERNAL_URL`-overridden + * by the caller) — Go's `Config.Auth.ExternalUrl`/`AuthExternalURL()` + * (`pkg/config/config.go:543-545`, `auth.go:401-405`): an explicit value + * wins over the `apiUrl`-derived fallback, same as `gotrue.service.ts`'s + * `LegacyBuildGotrueEnvInput.authExternalUrl` for the long-running + * container — this one-shot job must resolve to the SAME value so a fresh + * database's auth migration never disagrees with the container it's + * migrating for. + */ + readonly authExternalUrl?: string; + /** + * `LegacyLocalConfigValues.authSiteUrl` (already `SUPABASE_AUTH_SITE_URL`- + * overridden by the caller) — Go's `initAuthJob` reads the same overridden + * `utils.Config.Auth.SiteUrl` the long-running GoTrue container does + * (`apps/cli-go/internal/db/start/start.go:327`, `internal/start/ + * start.go:1365`), so this one-shot job must resolve to the SAME value, + * not the raw `config.auth.site_url` — same rationale as + * {@link authExternalUrl} above. + */ + readonly siteUrl: string; + /** `LegacyLocalConfigValues.anonKey`. */ + readonly anonKey: string; + /** `LegacyLocalConfigValues.serviceRoleKey`. */ + readonly serviceRoleKey: string; + /** Go's `utils.Config.Storage.TargetMigration` (`toml:"-"`, resolved from a version-pin file) — the caller passes `""` when absent, matching Go's zero-value default. */ + readonly storageTargetMigration: string; + readonly images: LegacyStartDbSetupImages; +} + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * Writes `sql` to `/` and execs it via `legacyExecSqlFile` + * (Go's `migration.NewMigrationFromReader(strings.NewReader(sql))` + + * `file.ExecBatch(ctx, conn)` on an in-memory string — there is no on-disk file in + * Go at all; this port needs one only because `legacyExecSqlFile` reads from the + * filesystem like every other `execMigrationBatch` caller). + */ +const legacyExecSqlConstant = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + tmpDir: string, + filename: string, + sql: string, +) { + const filePath = path.join(tmpDir, filename); + yield* fs.writeFileString(filePath, sql).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed to write ${filename}: ${errMessage(error)}`, + }), + ), + ); + yield* legacyExecSqlFile( + session, + fs, + path, + filePath, + (message) => new LegacyStartDbSetupError({ message }), + ); +}); + +/** + * Port of Go's `InitSchema14` (`start.go:256-266`): execs + * {@link LEGACY_START_DB_GLOBALS_SQL} then the major-version-appropriate initial + * schema. Only reached for `majorVersion <= 14` (the caller, `legacyStartInitSchema`, + * gates on that). + */ +const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + tmpDir: string, + majorVersion: number, +) { + yield* legacyExecSqlConstant( + session, + fs, + path, + tmpDir, + "globals.sql", + LEGACY_START_DB_GLOBALS_SQL, + ); + const schemaSql = + majorVersion === 13 + ? LEGACY_START_DB_INITIAL_SCHEMA_13_SQL + : LEGACY_START_DB_INITIAL_SCHEMA_14_SQL; + yield* legacyExecSqlConstant(session, fs, path, tmpDir, "initial-schema.sql", schemaSql); +}); + +/** + * Runs one PG15+ one-shot service-migration job to completion (Go's + * `utils.DockerRunJob` = `DockerRunOnceWithStream`, `docker.go:457-459,469-487`): + * foreground, same Docker network as `db`, no entrypoint override (Go's plain + * `Cmd` field), stdout discarded and stderr not teed (Go discards both outside + * `--debug` — `utils.GetDebugLogger()`, `logger.go:10-15`). A non-zero exit fails + * with the same shape as Go's `error running container: `. + */ +const legacyRunStartMigrateJob = Effect.fnUntraced(function* (opts: { + readonly image: string; + readonly env: Readonly>; + readonly cmd: ReadonlyArray; + readonly networkId: string; +}) { + const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal: + // host-gateway` extra host for every container it starts (`docker_linux.go`), + // including one-shot jobs routed through the same `DockerStart` path. + const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + const runOpts: LegacyDockerRunOpts = { + image: opts.image, + cmd: opts.cmd, + env: opts.env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts, + network: { _tag: "named", name: opts.networkId }, + // `opts.image` is already fully resolved (`start.handler.ts`'s `resolveImage`, which + // threads `projectEnvValues` through `legacyEnsureImagesCached`) — this layer's own + // ambient-only resolver must not re-resolve it. See `LegacyDockerRunOpts. + // skipImageResolve`'s doc comment. + skipImageResolve: true, + }; + const result = yield* docker + .runCapture(runOpts) + .pipe(Effect.mapError((cause) => new LegacyStartDbSetupError({ message: cause.message }))); + if (result.exitCode !== 0) { + return yield* Effect.fail( + new LegacyStartDbSetupError({ message: `error running container: exit ${result.exitCode}` }), + ); + } +}); + +/** Go's `initStorageJob` env (`start.go:297-317`) — deliberately distinct from `storage.service.ts`'s full container env, see this module's header. */ +function legacyStartStorageMigrateEnv(input: { + readonly targetMigration: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly jwtSecret: string; + readonly dbHost: string; + readonly dbPassword: string; + readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; +}): Record { + return { + DB_INSTALL_ROLES: "false", + DB_MIGRATIONS_FREEZE_AT: input.targetMigration, + ANON_KEY: input.anonKey, + SERVICE_KEY: input.serviceRoleKey, + PGRST_JWT_SECRET: input.jwtSecret, + DATABASE_URL: legacyStartInternalDbUrl( + "supabase_storage_admin", + input.dbHost, + input.dbPassword, + ), + FILE_SIZE_LIMIT: String(ramInBytes(input.fileSizeLimit)), + STORAGE_BACKEND: "file", + STORAGE_FILE_BACKEND_PATH: "/mnt", + TENANT_ID: "stub", + // TODO (matches Go's own TODO, `start.go:311`): https://github.com/supabase/storage-api/issues/55 + REGION: "stub", + GLOBAL_S3_BUCKET: "stub", + }; +} + +/** Go's `initAuthJob` env (`start.go:319-332`) — deliberately distinct from `gotrue.service.ts`'s full container env, see this module's header. */ +function legacyStartAuthMigrateEnv(input: { + readonly apiUrl: string; + readonly authExternalUrl: string | undefined; + readonly siteUrl: ProjectConfig["auth"]["site_url"]; + readonly jwtSecret: string; + readonly dbHost: string; + readonly dbPassword: string; +}): Record { + // Go's `AuthExternalURL()` (`pkg/config/config.go:543-545` -> `auth.GetExternalURL`): + // an explicit `auth.external_url` wins outright; only derive from `apiUrl` + // when it's unset — matching `gotrue.service.ts`'s identical preference + // chain for the long-running container. + const authExternalUrl = + input.authExternalUrl !== undefined && input.authExternalUrl.length > 0 + ? input.authExternalUrl + : `${input.apiUrl.replace(/\/+$/, "")}/auth/v1`; + return { + API_EXTERNAL_URL: authExternalUrl, + GOTRUE_LOG_LEVEL: "error", + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_DB_DATABASE_URL: legacyStartInternalDbUrl( + "supabase_auth_admin", + input.dbHost, + input.dbPassword, + ), + GOTRUE_SITE_URL: input.siteUrl, + GOTRUE_JWT_SECRET: input.jwtSecret, + }; +} + +/** + * Port of Go's `initSchema15` (`start.go:334-357`): up to three one-shot + * migrate jobs, each gated on its own service's `enabled` flag, run in Go's + * fixed order (realtime, storage, auth). + */ +const legacyStartInitSchema15 = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, +) { + const dbHost = legacyServiceContainerName("db", input.projectId); + const dbPassword = legacyStartInternalDbPassword(input.dbUrl); + + if (input.config.realtime.enabled) { + yield* legacyRunStartMigrateJob({ + image: input.images.realtime, + networkId: input.networkId, + env: legacyBuildRealtimeEnv({ + ipVersion: input.config.realtime.ip_version, + maxHeaderLength: input.config.realtime.max_header_length, + dbHost, + dbPassword, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }), + cmd: [ + "/app/bin/realtime", + "eval", + `{:ok, _} = Application.ensure_all_started(:realtime)\n{:ok, _} = Realtime.Tenants.health_check("${LEGACY_REALTIME_TENANT_ID}")`, + ], + }); + } + if (input.config.storage.enabled) { + // `legacyStartStorageMigrateEnv` parses `storage.file_size_limit` via + // `ramInBytes`, which throws on a malformed value — a plain synchronous + // throw here would become an uncaught Effect defect (`Effect.tapError`'s + // rollback trigger below only fires on typed `Fail` causes, never `Die` + // ones), leaking Postgres's already-created container/network/volume. + // Go fails this same malformed value at TOML-decode time, before any + // Docker work (`sizeInBytes.UnmarshalText`, `pkg/config/config.go:41-47`) + // — this can't be replicated literally here since Postgres is already up + // by this step, but surfacing it as a typed `LegacyStartDbSetupError` so + // rollback actually runs is the achievable equivalent, matching the same + // fix already applied to `resolveDbHealthTimeoutSeconds` and the + // long-running Storage container's own file-size-limit parsing + // (`start.handler.ts`). + const storageEnv = yield* Effect.try({ + try: () => + legacyStartStorageMigrateEnv({ + targetMigration: input.storageTargetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + fileSizeLimit: input.config.storage.file_size_limit, + }), + catch: (cause) => + new LegacyStartDbSetupError({ + message: `invalid config for storage: ${errMessage(cause)}`, + }), + }); + yield* legacyRunStartMigrateJob({ + image: input.images.storage, + networkId: input.networkId, + env: storageEnv, + cmd: ["node", "dist/scripts/migrate-call.js"], + }); + } + if (input.config.auth.enabled) { + yield* legacyRunStartMigrateJob({ + image: input.images.auth, + networkId: input.networkId, + env: legacyStartAuthMigrateEnv({ + apiUrl: input.apiUrl, + authExternalUrl: input.authExternalUrl, + siteUrl: input.siteUrl, + jwtSecret: input.jwtSecret, + dbHost, + dbPassword, + }), + cmd: ["gotrue", "migrate"], + }); + } +}); + +/** + * Port of Go's `initSchema` (`start.go:243-254`): prints the banner line once, + * then branches on PG major version — unconditionally, for BOTH branches, exactly + * matching Go's `fmt.Fprintln(w, "Initialising schema...")` running before the + * `if utils.Config.Db.MajorVersion <= 14` check. + */ +const legacyStartInitSchema = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, + tmpDir: string, +) { + const output = yield* Output; + yield* output.raw("Initialising schema...\n", "stderr"); + if (input.majorVersion <= 14) { + yield* legacyStartInitSchemaPre15( + input.session, + input.fs, + input.path, + tmpDir, + input.majorVersion, + ); + return; + } + yield* legacyStartInitSchema15(input); +}); + +/** + * Port of Go's `ApplyApiPrivileges` (`start.go:414-435`): tri-state on + * `api.auto_expose_new_tables` — `true` keeps the bundled initial-schema grants + * (no-op); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL}. Runs + * regardless of PG major version (unlike `initSchema`, this always execs SQL over + * `session` directly — it is never part of the PG15+ one-shot Docker jobs). + */ +const legacyStartApplyApiPrivileges = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, + tmpDir: string, + autoExposeNewTables: Option.Option, +) { + if (Option.isSome(autoExposeNewTables) && autoExposeNewTables.value) return; + yield* legacyExecSqlConstant( + input.session, + input.fs, + input.path, + tmpDir, + "revoke-api-privileges.sql", + LEGACY_START_REVOKE_API_PRIVILEGES_SQL, + ); +}); + +/** + * Port of Go's `initCurrentBranch` (`start.go:233-241`): writes + * `supabase/.branches/_current_branch` = `"main"` (Go's `CurrBranchPath`, + * `apps/cli-go/internal/utils/misc.go:99` = `filepath.Join(SupabaseDirPath, + * ".branches", "_current_branch")`) only if it doesn't already exist. No existing + * TS constant for this path — `legacy/commands/db/branch/*` are Management-API + * cloud-branch commands, unrelated to this local file — so it's inlined here, + * the only current consumer (per "Hoist Before You Duplicate"). Exported (rather + * than folded into {@link legacyStartSetupLocalDatabase}) because Go calls it + * unconditionally, not just on a fresh volume — see this module's header. + */ +export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +) { + const currentBranchPath = path.join(workdir, "supabase", ".branches", "_current_branch"); + const exists = yield* fs.exists(currentBranchPath).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + }), + ), + ); + if (exists) return; + yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + }), + ), + ); + yield* fs.writeFileString(currentBranchPath, "main").pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + }), + ), + ); +}); + +/** + * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's + * header for the exact Go call chain and line-range citations. Call once, right + * after the `db` container's healthcheck passes on a fresh volume (Go's + * `NoBackupVolume` gate); the caller decides that gating, this function performs + * no health/readiness checks of its own. + */ +export const legacyStartSetupLocalDatabase = ( + input: LegacyStartSetupLocalDatabaseInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError, + Output | LegacyDockerRun | RuntimeInfo +> => + Effect.gen(function* () { + const { session, fs, path, workdir } = input; + + const toml = yield* legacyCheckDbToml(fs, path, workdir); + + // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). + yield* Effect.scoped( + Effect.gen(function* () { + const tmpDir = yield* fs + .makeTempDirectoryScoped({ prefix: "supabase-start-db-setup-" }) + .pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed to create temp directory: ${errMessage(error)}`, + }), + ), + ); + yield* legacyStartInitSchema(input, tmpDir); + yield* legacyStartApplyApiPrivileges(input, tmpDir, toml.baseline.apiAutoExposeNewTables); + }), + ); + + // "Create vault secrets first so roles.sql can reference them" (start.go:390). + yield* legacyUpsertVaultSecrets(session, toml.vault); + + // Custom-roles seed (start.go:394-398, pkg/migration/seed.go:84-97): Go's + // `SeedGlobals` prints "Seeding globals from roles.sql..." BEFORE attempting + // to read the file, then tolerates a missing file (`errors.Is(err, + // os.ErrNotExist)`); any other read/exec error propagates. Reproduced here as + // an unconditional print followed by an existence check ahead of the read + // (via `legacyExecSqlFile`, not `legacySeedGlobals` — reusing `legacySeedGlobals` + // would need the missing-file case to unwind through `execMigrationBatch`'s + // shared, flattened error-mapping contract in `legacy-migration-apply.ts`, + // which every other caller of that file also relies on and which is out of + // scope to change here) rather than a caught not-found error, since there is + // no meaningful TOCTOU concern in this CLI context. + const customRolesPath = path.join(workdir, "supabase", "roles.sql"); + const output = yield* Output; + yield* output.raw(`Seeding globals from ${path.basename(customRolesPath)}...\n`, "stderr"); + const rolesExist = yield* fs.exists(customRolesPath).pipe( + Effect.mapError( + (error) => + new LegacyStartDbSetupError({ + message: `failed to check roles.sql: ${errMessage(error)}`, + }), + ), + ); + if (rolesExist) { + yield* legacyExecSqlFile( + session, + fs, + path, + customRolesPath, + (message) => new LegacyStartDbSetupError({ message }), + ); + } + + // apply.MigrateAndSeed(ctx, "", conn, fsys) — empty version = every pending + // migration, matching `SetupLocalDatabase`'s own call in the `start` context + // (start.go:368). + yield* legacyMigrateAndSeed(session, fs, path, workdir, "", { + migrationsEnabled: toml.migrationsEnabled, + seed: toml.seed, + }); + + // Go's best-effort pgcache catalog warning (`pgcache.TryCacheMigrationsCatalog`, + // start.go:371-379) is not ported (no output impact) — same accepted, documented + // divergence as `db/reset/reset.handler.ts`. + // + // `initCurrentBranch` (start.go:233-241) is NOT called here — see this + // module's header for why it moved to the caller instead. + }); diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts new file mode 100644 index 0000000000..8f2decfc6f --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts @@ -0,0 +1,523 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProjectConfig } from "@supabase/config"; +import { ProjectConfigSchema } from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; + +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import { LegacyDockerRunError } from "../../../shared/legacy-docker-run.errors.ts"; +import { + LegacyStartDbSetupError, + legacyStartInitCurrentBranch, + legacyStartSetupLocalDatabase, + type LegacyStartSetupLocalDatabaseInput, +} from "./db-setup.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +/** + * Fingerprints unique to each transcribed SQL constant — see `db-setup.ts`'s + * templates. No trailing `;`: `legacySplitAndTrim` strips it from every + * executed statement before `session.exec` sees it. + * + * `GLOBALS`/`SCHEMA_13`/`REVOKE_PRIVILEGES` are checked as SUBSTRINGS: each is + * embedded inside a larger executed block (a preceding comment, a `DO`-style + * conditional, or the sibling `alter default privileges` line). `SCHEMA_14` + * is checked with `.endsWith` instead — `CREATE SCHEMA IF NOT EXISTS graphql` + * is itself preceded by a comment block (so a plain equality check would + * fail), but a naive substring check would also match 14.sql's unrelated + * `CREATE SCHEMA IF NOT EXISTS graphql_public` statement, since `graphql` is + * a prefix of `graphql_public`. + */ +const GLOBALS_FINGERPRINT = "CREATE ROLE anon"; +const SCHEMA_13_FINGERPRINT = + "CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION"; +const SCHEMA_14_FINGERPRINT_SUFFIX = "CREATE SCHEMA IF NOT EXISTS graphql"; +const REVOKE_PRIVILEGES_FINGERPRINT = + "revoke execute on functions from anon, authenticated, service_role"; + +function fakeSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + calls.push({ kind: "exec", sql }); + }), + query: (sql, params) => + Effect.sync(() => { + calls.push({ kind: "query", sql, params }); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +function mockDockerRun(opts: { exitCode?: number } = {}) { + const runs: Array = []; + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(opts.exitCode ?? 0), + runCapture: (runOpts) => { + runs.push(runOpts); + return Effect.succeed({ + exitCode: opts.exitCode ?? 0, + stdout: new Uint8Array(), + stderr: "", + }); + }, + runStream: () => Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }), + }); + return { layer, runs }; +} + +function mockDockerRunFails() { + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + runCapture: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + runStream: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + }); + return { layer }; +} + +function makeWorkdir(): string { + return mkdtempSync(join(tmpdir(), "legacy-db-setup-")); +} + +function writeConfigToml(workdir: string, content: string): void { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), content); +} + +const defaultConfig: ProjectConfig = decodeConfig({}); + +function baseInput( + workdir: string, + session: LegacyDbSession, + overrides: Partial = {}, +): Omit { + return { + session, + workdir, + config: defaultConfig, + majorVersion: 17, + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + apiUrl: "http://127.0.0.1:54321", + siteUrl: defaultConfig.auth.site_url, + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + storageTargetMigration: "", + images: { + realtime: "public.ecr.aws/supabase/realtime:v2.34.7", + storage: "public.ecr.aws/supabase/storage-api:v1.0.0", + auth: "public.ecr.aws/supabase/gotrue:v2.170.0", + }, + ...overrides, + }; +} + +const run = ( + input: Omit, + out: ReturnType, + docker: ReturnType | ReturnType, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyStartSetupLocalDatabase({ ...input, fs, path }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + out.layer, + docker.layer, + mockRuntimeInfo({ platform: "darwin" }), + ), + ), + ); + +describe("legacyStartSetupLocalDatabase", () => { + describe("PG <= 14 vs PG >= 15 schema branch", () => { + it.effect("PG14: execs globals + the PG14 initial schema, runs no one-shot docker jobs", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(true); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); + expect(docker.runs.length).toBe(0); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("PG13: execs globals + the PG13 initial schema, not the PG14 one", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 13 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(true); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); + expect(docker.runs.length).toBe(0); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("PG15+: runs one-shot docker jobs, execs no schema SQL at all", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(GLOBALS_FINGERPRINT))).toBe(false); + expect(execSql.some((sql) => sql.includes(SCHEMA_13_FINGERPRINT))).toBe(false); + expect(execSql.some((sql) => sql.endsWith(SCHEMA_14_FINGERPRINT_SUFFIX))).toBe(false); + // Default config: realtime, storage, and auth are all enabled. + expect(docker.runs.length).toBe(3); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect('prints "Initialising schema..." once, for either branch', () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( + Effect.map(() => { + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + }); + + describe("PG15+ one-shot job gating", () => { + it.effect("gates each job independently on its own service's enabled flag", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(2); + const images = docker.runs.map((r) => r.image); + expect(images).toContain("public.ecr.aws/supabase/realtime:v2.34.7"); + expect(images).toContain("public.ecr.aws/supabase/gotrue:v2.170.0"); + expect(images).not.toContain("public.ecr.aws/supabase/storage-api:v1.0.0"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("runs nothing when all three services are disabled", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + auth: { enabled: false }, + }); + return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(0); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect( + "the realtime job's env matches `legacyBuildRealtimeEnv` on the internal db address + jwks", + () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + return run( + baseInput(workdir, session, { + majorVersion: 15, + config, + projectId: "myproj", + jwks: '{"keys":["stub"]}', + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(1); + const job = docker.runs[0]!; + expect(job.cmd[0]).toBe("/app/bin/realtime"); + expect(job.cmd[1]).toBe("eval"); + expect(job.cmd[2]).toContain('Realtime.Tenants.health_check("realtime-dev")'); + expect(job.env["DB_HOST"]).toBe("supabase_db_myproj"); + expect(job.env["API_JWT_JWKS"]).toBe('{"keys":["stub"]}'); + expect(job.network).toEqual({ _tag: "named", name: "supabase_network_proj" }); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect( + "the auth job's env derives API_EXTERNAL_URL from apiUrl and carries site_url + jwt secret", + () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ realtime: { enabled: false }, storage: { enabled: false } }); + return run( + baseInput(workdir, session, { + majorVersion: 15, + config, + apiUrl: "http://127.0.0.1:54321/", + }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(1); + const job = docker.runs[0]!; + expect(job.cmd).toEqual(["gotrue", "migrate"]); + expect(job.env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(job.env["GOTRUE_SITE_URL"]).toBe(config.auth.site_url); + expect(job.env["GOTRUE_JWT_SECRET"]).toBe( + "super-secret-jwt-token-with-at-least-32-characters-long", + ); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect( + "the auth job's GOTRUE_SITE_URL reflects the caller's resolved siteUrl, not the raw config value", + () => { + // `siteUrl` is already SUPABASE_AUTH_SITE_URL-overridden by the caller + // (`start.handler.ts`'s `values.authSiteUrl`) — the one-shot auth + // migration job must agree with the long-running GoTrue container, + // not fall back to reading the un-overridden `config.auth.site_url`. + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: false }, + storage: { enabled: false }, + auth: { site_url: "http://raw-config-value.example" }, + }); + return run( + baseInput(workdir, session, { + majorVersion: 15, + config, + apiUrl: "http://127.0.0.1:54321/", + siteUrl: "http://env-overridden-value.example", + }), + out, + docker, + ).pipe( + Effect.map(() => { + const job = docker.runs[0]!; + expect(job.env["GOTRUE_SITE_URL"]).toBe("http://env-overridden-value.example"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect("a non-zero exit from a one-shot job fails the whole pipeline", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun({ exitCode: 1 }); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyStartDbSetupError); + expect((error as LegacyStartDbSetupError).message).toBe( + "error running container: exit 1", + ); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + }); + + describe("ApplyApiPrivileges tri-state", () => { + it.effect("auto_expose_new_tables = true is a no-op (no revoke SQL exec'd)", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, "[api]\nauto_expose_new_tables = true\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("auto_expose_new_tables = false execs the revoke SQL", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, "[api]\nauto_expose_new_tables = false\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect( + "unset (no config.toml at all) matches the false behavior — execs the revoke SQL", + () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(REVOKE_PRIVILEGES_FINGERPRINT))).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + }); + + describe("vault upsert + custom-roles seed", () => { + it.effect("upserts vault secrets before seeding supabase/roles.sql", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, '[db.vault]\nmy_secret = "shh"\n'); + writeFileSync( + join(workdir, "supabase", "roles.sql"), + "grant select on all tables in schema public to custom_role;", + ); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + const vaultCallIndex = calls.findIndex( + (c) => c.kind === "query" && c.sql.includes("vault.create_secret"), + ); + const rolesCallIndex = calls.findIndex( + (c) => c.kind === "exec" && c.sql.includes("custom_role"), + ); + expect(vaultCallIndex).toBeGreaterThanOrEqual(0); + expect(rolesCallIndex).toBeGreaterThanOrEqual(0); + expect(vaultCallIndex).toBeLessThan(rolesCallIndex); + expect(out.rawChunks.map((c) => c.text)).toContain("Seeding globals from roles.sql...\n"); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect( + "prints the seed message even when supabase/roles.sql is absent, and does not error", + () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( + Effect.map(() => { + // Go's `SeedGlobals` prints before attempting the read (`pkg/migration/ + // seed.go:84-97`) — a missing roles.sql is tolerated, not skipped. + expect(out.rawChunks.map((c) => c.text)).toContain( + "Seeding globals from roles.sql...\n", + ); + const rolesCallIndex = calls.findIndex( + (c) => c.kind === "exec" && c.sql.includes("custom_role"), + ); + expect(rolesCallIndex).toBe(-1); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + }); +}); + +describe("legacyStartInitCurrentBranch", () => { + it.effect('writes supabase/.branches/_current_branch = "main" when absent', () => { + const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyStartInitCurrentBranch(fs, path, workdir); + const content = yield* fs.readFileString( + join(workdir, "supabase", ".branches", "_current_branch"), + ); + expect(content).toBe("main"); + }).pipe( + Effect.provide(BunServices.layer), + Effect.map(() => { + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("leaves an existing _current_branch file untouched", () => { + const workdir = makeWorkdir(); + const branchesDir = join(workdir, "supabase", ".branches"); + mkdirSync(branchesDir, { recursive: true }); + writeFileSync(join(branchesDir, "_current_branch"), "feature-x"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyStartInitCurrentBranch(fs, path, workdir); + const content = yield* fs.readFileString(join(branchesDir, "_current_branch")); + expect(content).toBe("feature-x"); + }).pipe( + Effect.provide(BunServices.layer), + Effect.map(() => { + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts b/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts new file mode 100644 index 0000000000..3e4462b57b --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts @@ -0,0 +1,463 @@ +/** + * Assembles `docker create [cmd...]` argv for the per-service + * containers Go's `supabase start` creates directly against the Docker Engine + * SDK (`utils.DockerStart`, `apps/cli-go/internal/utils/docker.go:363-440`). + * + * Go never shells out to `docker` — it builds `container.Config` / + * `container.HostConfig` / `network.NetworkingConfig` structs and calls + * `Docker.ContainerCreate` over the Engine API. This module is the CLI-shell + * translation of that same struct surface into `docker create` CLI flags, so + * every field here is documented against the exact Go struct field (and, where + * useful, the exact call site) it reproduces. + * + * Unlike the Engine API, `docker create`'s argv has no inherent "field order" + * — Docker parses flags independently of position. The order emitted by + * {@link buildLegacyStartContainerCreateArgs} below is therefore this module's + * own fixed, deterministic convention (grouped: identity → env → volumes → + * ports → healthcheck → restart/security → network → labels → + * entrypoint/image/cmd), chosen for readability and stable unit-test + * snapshots — it carries no Go parity obligation, unlike every flag-to-field + * mapping it emits. + * + * Surveyed call sites (every `container.Config{`/`network.NetworkingConfig{` + * block across the Go source, per the task's "read every one" instruction): + * - `apps/cli-go/internal/start/start.go:350-394` Logflare/analytics + * - `apps/cli-go/internal/start/start.go:396-484` Vector + * - `apps/cli-go/internal/start/start.go:486-627` Kong + * - `apps/cli-go/internal/start/start.go:629-851` GoTrue + * - `apps/cli-go/internal/start/start.go:853-901` Mailpit/Inbucket + * - `apps/cli-go/internal/start/start.go:903-958` Realtime + * - `apps/cli-go/internal/start/start.go:960-992` PostgREST/Rest + * - `apps/cli-go/internal/start/start.go:994-1057` Storage + * - `apps/cli-go/internal/start/start.go:1059-1099` Storage ImgProxy + * - `apps/cli-go/internal/start/start.go:1110-1146` pg-meta + * - `apps/cli-go/internal/start/start.go:1148-1191` Studio + * - `apps/cli-go/internal/start/start.go:1193-1268` Pooler + * - `apps/cli-go/internal/db/start/start.go:63-131` Postgres (the db container) + * + * Fields deliberately NOT modelled, because none of the 13 call sites above + * set them on `container.Config`/`container.HostConfig` (verified by grepping + * every block for these field names): `User`, `WorkingDir`, `Tty`, `CapAdd`, + * `Ulimits`, `ShmSize`. Add them here — following the same "optional field, + * flag omitted unless present" shape as every other field below — the day a + * call site actually needs one; there is no value in modelling Docker surface + * this builder never has to reproduce. + * + * One field has no Go struct equivalent at all: {@link LegacyStartContainerSpec.secretFiles}. + * It exists purely because this module's own "shell out to `docker create`" + * architecture (unlike Go's direct Engine API calls) has an argv-exposure + * problem `container.Config`/`container.HostConfig` never had — see that + * field's doc comment, and `container-lifecycle.ts`'s `legacyStartContainer`, + * for the mitigation. + */ + +import { + legacyBindMountSpecSource, + legacyIsBindMountSource, +} from "../../../shared/legacy-docker-bind-classify.ts"; + +/** + * `container.HealthConfig` (`docker/docker/api/types/container`). Not + * exported on its own — callers reference it structurally through + * {@link LegacyStartContainerSpec.healthcheck}; nothing outside this module + * needs to name the shape directly. + */ +interface LegacyStartHealthcheckSpec { + /** + * Go's `HealthConfig.Test` exec form (`["CMD", ...args]`) or shell form + * (`["CMD-SHELL", script]`). See {@link legacyBuildHealthCmdArg} for exactly + * how each form becomes the single `--health-cmd` string docker CLI expects. + */ + readonly test: ReadonlyArray; + /** `HealthConfig.Interval`, already-whole seconds → `--health-interval s`. */ + readonly intervalSeconds?: number; + /** `HealthConfig.Timeout` → `--health-timeout s`. */ + readonly timeoutSeconds?: number; + /** `HealthConfig.Retries` → `--health-retries `. */ + readonly retries?: number; + /** + * `HealthConfig.StartPeriod` → `--health-start-period s`. Only Logflare + * and Mailpit set this (`start.go:371`, `start.go:882`); every other + * healthcheck leaves Go's zero value, which docker treats as "unset" — so + * this stays optional and the flag is omitted, not emitted as `0s`. + */ + readonly startPeriodSeconds?: number; +} + +/** + * `-p :[/]` — one + * `container.HostConfig.PortBindings` entry. Not exported; referenced + * structurally through {@link LegacyStartContainerSpec.ports}. + */ +interface LegacyStartPortBindingSpec { + readonly hostPort: string; + readonly containerPort: string; + /** Defaults to `"tcp"` (omitted from the flag) — no call site uses `"udp"` today. */ + readonly protocol?: "tcp" | "udp"; +} + +/** + * `--expose [/]` — one `container.Config.ExposedPorts` + * entry with no matching `PortBindings` entry (kept separate from + * {@link LegacyStartPortBindingSpec} on purpose, see the doc comment on + * {@link LegacyStartContainerSpec.exposedPorts}). Not exported; referenced + * structurally through {@link LegacyStartContainerSpec.exposedPorts}. + */ +interface LegacyStartExposedPortSpec { + readonly containerPort: string; + readonly protocol?: "tcp" | "udp"; +} + +/** + * One entry a caller must stage as a HOST temp file and bind-mount read-only + * into the container — see {@link LegacyStartContainerSpec.secretFiles}'s doc + * comment for the full contract. Not exported on its own — callers reference + * it structurally through that field; nothing outside this module needs to + * name the shape directly. + */ +interface LegacyStartSecretFileSpec { + /** The fixed path INSIDE the container the caller's generated bind mount targets. */ + readonly containerPath: string; + /** The secret content to write to a HOST temp file — never emitted into argv. */ + readonly content: string; +} + +export interface LegacyStartContainerSpec { + /** `container.Config.Image` (already resolved/pulled — resolution is out of scope here). */ + readonly image: string; + /** The 4th `DockerStart` positional argument — `--name`. */ + readonly containerName: string; + /** + * `container.Config.Hostname`. Only Logflare sets this (`start.go:353`, + * `Hostname: "127.0.0.1"`); every other service leaves it unset and relies + * on Docker's default (the container's own short ID). + */ + readonly hostname?: string; + /** + * `container.Config.Env`, reshaped from Go's `KEY=value` string slice into a + * map. Emitted as the key-only `-e KEY` form — see the doc comment on + * {@link buildLegacyStartContainerCreateArgs} — so secret values (JWT + * secrets, SMTP passwords, API keys — every one of these containers carries + * at least one) never appear in this process's own argv. + */ + readonly env: Readonly>; + /** + * Entrypoint/`Cmd`-script secret content that must land at a specific path + * INSIDE the container without ever appearing in this process's own + * `docker create` argv (`ps aux`/`/proc//cmdline`, CWE-214/522) — the + * entrypoint/`Cmd` analogue of {@link env}'s key-only `-e KEY` protection. + * Kong/Postgres/Supavisor all heredoc or shell-embed secret-bearing content + * (Kong's `kong.yml`/TLS private key, Postgres's pgsodium root key, + * Supavisor's rendered `pooler.exs`) directly into their entrypoint script + * or `Cmd` — safe in Go's Engine-API architecture (never a subprocess's own + * argv) but not in this port's, which shells out to a real `docker create`. + * + * NOT consumed here: {@link buildLegacyStartContainerCreateArgs} stays + * pure/no-I/O and never reads this field. `container-lifecycle.ts`'s + * `legacyStartContainer` is the sole consumer — it writes each entry's + * `content` to a HOST-side temp file (mode `0600`, in a fresh temp + * directory) and appends a `::ro` bind (the + * bind's SOURCE is a generated temp-file path, never the secret itself — + * safe in argv) to {@link binds} BEFORE this builder ever sees the spec, + * then removes the temp file/directory once the container is created and + * started. Generic by design — any future service's spec can set this, not + * just the three call sites that need it today. + */ + readonly secretFiles?: ReadonlyArray; + /** + * `container.Config.Entrypoint`'s first element. Docker CLI's `--entrypoint` + * only accepts a single executable/script name (unlike the Engine API field, + * which is a full argv array) — the remaining Go `Entrypoint` elements are + * reproduced via {@link cmd} instead, exactly like the existing `docker run` + * precedent (`legacy-docker-run.service.ts`'s `entrypoint`/`cmd` split). + * E.g. Go's `Entrypoint: ["sh", "-c", script]` (Logflare/Vector/Kong/db) + * becomes `entrypoint: "sh", cmd: ["-c", script]`. + */ + readonly entrypoint?: string; + /** + * Trailing argv tokens placed after the image. Always emitted — unlike the + * `docker run` precedent's comment (which only ever pairs `cmd` with + * `entrypoint`), Pooler (`start.go:1234-1237`) sets `container.Config.Cmd` + * with NO `Entrypoint` at all: it overrides the pooler image's default `CMD` + * while keeping its own `ENTRYPOINT`. Docker CLI's trailing-tokens-after-image + * convention already covers both cases identically (args to `--entrypoint` + * when set, or a `CMD` override of the image default when not), so `cmd` is + * independent of {@link entrypoint} here. + */ + readonly cmd?: ReadonlyArray; + /** + * `container.HostConfig.Binds` — `"source:target[:mode]"` bind-mount strings + * or `"volumeName:target"` named-volume strings (Go's `loader.ParseVolume` + * classification, see {@link legacyIsBindMountSource}). Named-volume + * creation itself (`Docker.VolumeCreate`, `docker.go:407-415`) is a + * higher-level orchestration concern, not this pure argv builder's job. + */ + readonly binds: ReadonlyArray; + /** `container.HostConfig.VolumesFrom` — ImgProxy only (`start.go:1084`, mounts Storage's volumes). */ + readonly volumesFrom?: ReadonlyArray; + /** + * `container.HostConfig.Tmpfs` (`map[mountPath]mountOptions`) — only the + * Postgres container sets this, and only on PG ≤ 14 + * (`apps/cli-go/internal/db/start/start.go:127-129`: + * `map[string]string{"/docker-entrypoint-initdb.d": ""}`). An empty options + * string means "no extra tmpfs mount options" (`--tmpfs ` with no + * `:options` suffix); a non-empty value is joined as `:`. + */ + readonly tmpfs?: Readonly>; + /** + * `container.HostConfig.PortBindings` — ports published to the host via + * `-p`. Distinct from {@link exposedPorts}: publishing a port with `-p` + * already implies exposing it (docker CLI infers `ExposedPorts` from `-p` + * the same way Go's own `container.Config.ExposedPorts` happens to overlap + * with `PortBindings` in the Logflare example, `start.go:373` vs `:377`). + */ + readonly ports?: ReadonlyArray; + /** + * `container.Config.ExposedPorts` entries that have NO matching + * `PortBindings` entry — i.e. ports declared reachable on the Docker network + * but never published to the host. This is a real, recurring pattern, not a + * one-off: GoTrue (`start.go:825`, port 9999) and Realtime (`start.go:930`, + * port 4000) expose a port with zero `PortBindings`; Kong exposes 3 ports + * (`start.go:602-606`) but only ever publishes one of them depending on TLS; + * Pooler exposes 3 ports (`start.go:1238-1242`) but only ever publishes the + * one matching its configured pool mode. A single `ports` field (CLI `-p`) + * cannot express "exposed but not published", so it is kept as its own field + * rather than folded into {@link ports} with an optional host side. + */ + readonly exposedPorts?: ReadonlyArray; + /** `container.Config.Healthcheck`. Omitted entirely for Kong and PostgREST — see `start.go:975` ("PostgREST does not expose a shell for health check") — so no `--health-*` flags are emitted for those services. */ + readonly healthcheck?: LegacyStartHealthcheckSpec; + /** + * `container.HostConfig.RestartPolicy.Name`. Every one of the 13 surveyed + * call sites uses `container.RestartPolicyUnlessStopped` (verified — grep + * `RestartPolicy` across `start.go`/`db/start/start.go` returns only + * `RestartPolicyUnlessStopped`), but the full docker CLI `--restart` enum is + * supported for completeness/future callers, per the task brief. + */ + readonly restartPolicy?: "unless-stopped" | "no" | "always" | "on-failure"; + /** + * `container.HostConfig.SecurityOpt`. Only Vector sets this + * (`start.go:441`, `"label:disable"`, when mounting a non-root Docker + * socket) — cleared globally under Bitbucket Pipelines, see + * {@link legacyApplyBitbucketStartContainerFilter}. + */ + readonly securityOpt?: ReadonlyArray; + /** + * `container.HostConfig.ExtraHosts`. Populated by `DockerStart` itself from + * the platform-specific `extraHosts` package var (`docker_linux.go`, + * `docker_darwin.go`, `docker_windows.go`), not by any individual + * `container.Config` literal — every container gets the same extra hosts. + * That orchestration (merging in the platform default) is a caller concern; + * this builder just emits whatever is passed here. + */ + readonly extraHosts?: ReadonlyArray; + /** + * `container.HostConfig.NetworkMode`, resolved by `DockerStart` + * (`docker.go:379-383`) to either the `--network-id` override or + * `utils.NetId` — never set per-container in any surveyed call site. + */ + readonly networkId: string; + /** + * `network.NetworkingConfig.EndpointsConfig[NetId].Aliases` — the per-service + * alias array (e.g. `utils.RealtimeAliases = ["realtime", tenantId]`, + * `apps/cli-go/internal/utils/config.go:40`). The container's OWN name is + * already DNS-resolvable on a user-defined network without any alias; this + * field only adds the short/extra names other containers' env vars and + * templates reference by. + */ + readonly networkAliases?: ReadonlyArray; + /** + * `container.Config.Labels`, merged in by `DockerStart` itself + * (`docker.go:372-376`: `CliProjectLabel`/`composeProjectLabel`, both the + * sanitized project id) rather than set per-service — this builder emits + * whatever map is passed here, unconditionally. + */ + readonly labels: Readonly>; +} + +function formatDockerDurationSeconds(seconds: number): string { + return `${seconds}s`; +} + +/** + * Quotes a single argv-style argument for safe embedding in a POSIX shell + * command line: wraps it in single quotes, escaping any embedded single quote + * as `'\''` (close quote, escaped literal quote, reopen quote) — the standard + * POSIX-portable technique (equivalent to Python's `shlex.quote`). Arguments + * containing only characters that never need quoting are returned unchanged + * for readability. + */ +function legacyShellQuoteArg(arg: string): string { + if (arg.length > 0 && /^[A-Za-z0-9_\-./:@%,+=]+$/.test(arg)) return arg; + return `'${arg.replaceAll("'", "'\\''")}'`; +} + +/** + * Converts Go's `HealthConfig.Test` into the single string `docker create + * --health-cmd` expects. + * + * Docker CLI's `--health-cmd` flag has no exec-form equivalent: whatever + * string it is given always becomes `HealthConfig.Test = ["CMD-SHELL", + * value]` on the resulting container (there is no CLI flag that produces the + * exec-form `["CMD", ...]` Go sometimes uses directly over the Engine API — + * only a Dockerfile `HEALTHCHECK` instruction or the raw API can do that). So: + * + * - `["CMD-SHELL", script]` (pg-meta/Studio, `start.go:1125`/`:1166`) — `script` + * is already the exact string Go's own `CMD-SHELL` form would run verbatim + * via the container's `/bin/sh -c`; it is forwarded completely unmodified. + * No quoting is applied on this side because this string travels as a + * single argv element to the spawned `docker` process (no host-side shell + * parses it) — only Docker's own eventual in-container `/bin/sh -c + * ` interprets it, exactly reproducing Go's `Test` value untouched. + * - `["CMD", ...args]` (exec form — e.g. Logflare's `["CMD", "curl", "-sSfL", + * "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"]`, + * `start.go:365-366`) — since `--health-cmd` always produces a `CMD-SHELL` + * test, each `args` element is POSIX-shell-quoted individually (via + * {@link legacyShellQuoteArg}) and joined with spaces, so that when Docker + * later runs `/bin/sh -c ""` inside the container, the shell + * re-splits it back into the exact same argv the exec form specified — + * including an argument containing spaces or embedded quotes, which none of + * the current 14 services happen to need but which this conversion must + * still get right. + */ +export function legacyBuildHealthCmdArg(test: ReadonlyArray): string { + const [mode, ...rest] = test; + if (mode === "CMD-SHELL") return rest[0] ?? ""; + return rest.map(legacyShellQuoteArg).join(" "); +} + +function formatPortBindingFlag(port: LegacyStartPortBindingSpec): string { + const suffix = port.protocol === "udp" ? "/udp" : ""; + return `${port.hostPort}:${port.containerPort}${suffix}`; +} + +function formatExposedPortFlag(port: LegacyStartExposedPortSpec): string { + const suffix = port.protocol === "udp" ? "/udp" : ""; + return `${port.containerPort}${suffix}`; +} + +function buildHealthcheckArgs(healthcheck: LegacyStartHealthcheckSpec): ReadonlyArray { + const args: Array = ["--health-cmd", legacyBuildHealthCmdArg(healthcheck.test)]; + if (healthcheck.intervalSeconds !== undefined) { + args.push("--health-interval", formatDockerDurationSeconds(healthcheck.intervalSeconds)); + } + if (healthcheck.timeoutSeconds !== undefined) { + args.push("--health-timeout", formatDockerDurationSeconds(healthcheck.timeoutSeconds)); + } + if (healthcheck.retries !== undefined) { + args.push("--health-retries", String(healthcheck.retries)); + } + if (healthcheck.startPeriodSeconds !== undefined) { + args.push("--health-start-period", formatDockerDurationSeconds(healthcheck.startPeriodSeconds)); + } + return args; +} + +/** + * Docker/Podman CLI env vars that configure the CLIENT itself — which daemon + * it connects to — rather than a value for the container being created. A + * container spec's own `env` can legitimately need to set one of these (e.g. + * Vector's `DOCKER_HOST=http://host.docker.internal:`, set by + * `legacyResolveVectorDockerSocketPlan` for a `tcp`/`npipe` daemon host so + * Vector can reach the real daemon from inside its own container), but that + * value must never be inherited by the spawned `docker`/`podman create` + * PROCESS's own environment: doing so would hijack which daemon that process + * itself talks to before the container even exists (see + * `legacyDockerCreateContainer`, `container-lifecycle.ts`, which filters these + * keys out of the env it hands to the spawned process for exactly this + * reason). These are not secrets, so unlike the rest of `spec.env` they are + * safe to emit inline as `-e KEY=value` instead of the key-only form. + */ +const DOCKER_CLIENT_ENV_KEYS: ReadonlySet = new Set([ + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_CONTEXT", + "DOCKER_API_VERSION", +]); + +/** Whether `key` configures the Docker/Podman CLI client itself — see {@link DOCKER_CLIENT_ENV_KEYS}. */ +export function legacyIsDockerClientEnvKey(key: string): boolean { + return DOCKER_CLIENT_ENV_KEYS.has(key); +} + +/** + * Assemble the `docker create` argv for one `supabase start` service + * container. Pure (no Effect) so every flag mapping is unit-testable in + * isolation, matching the `buildLegacyDockerArgs` (`docker run`) precedent. + * + * `"create"` is argv[0] — the caller (`legacy-container-cli.ts`'s + * `spawnContainerCli`/`containerCliExitCode`) prepends only the `docker`/ + * `podman` binary itself, exactly like `buildLegacyDockerArgs` returning + * `"run"` as its own argv[0]. + * + * Env is emitted in the key-only `-e KEY` form (never `-e KEY=value`) for the + * same CWE-214/209 reason as `buildLegacyDockerArgs`: these containers' + * env carries JWT secrets, SMTP credentials, API keys, and DB passwords, none + * of which may appear in this process's own argv (`ps aux` / + * `/proc//cmdline`). The spawned `docker create`'s own child environment + * supplies each value — a later caller's responsibility, not this builder's. + * The exception is {@link legacyIsDockerClientEnvKey} keys, which are emitted + * inline as `-e KEY=value` instead — see that function's doc comment. + */ +export function buildLegacyStartContainerCreateArgs( + spec: LegacyStartContainerSpec, +): ReadonlyArray { + return [ + "create", + "--name", + spec.containerName, + ...(spec.hostname === undefined ? [] : ["--hostname", spec.hostname]), + ...Object.entries(spec.env).flatMap(([key, value]) => + legacyIsDockerClientEnvKey(key) ? ["-e", `${key}=${value}`] : ["-e", key], + ), + ...spec.binds.flatMap((bind) => ["-v", bind]), + ...(spec.volumesFrom ?? []).flatMap((source) => ["--volumes-from", source]), + ...Object.entries(spec.tmpfs ?? {}).flatMap(([path, options]) => [ + "--tmpfs", + options.length > 0 ? `${path}:${options}` : path, + ]), + ...(spec.ports ?? []).flatMap((port) => ["-p", formatPortBindingFlag(port)]), + ...(spec.exposedPorts ?? []).flatMap((port) => ["--expose", formatExposedPortFlag(port)]), + ...(spec.healthcheck === undefined ? [] : buildHealthcheckArgs(spec.healthcheck)), + ...(spec.restartPolicy === undefined ? [] : ["--restart", spec.restartPolicy]), + ...(spec.securityOpt ?? []).flatMap((opt) => ["--security-opt", opt]), + ...(spec.extraHosts ?? []).flatMap((host) => ["--add-host", host]), + "--network", + spec.networkId, + ...(spec.networkAliases ?? []).flatMap((alias) => ["--network-alias", alias]), + ...Object.entries(spec.labels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), + // `--entrypoint` must precede the image (it is a `docker create` flag). + ...(spec.entrypoint === undefined ? [] : ["--entrypoint", spec.entrypoint]), + spec.image, + ...(spec.cmd ?? []), + ]; +} + +/** + * Mirror Go's `DockerStart` Bitbucket Pipelines handling + * (`apps/cli-go/internal/utils/docker.go:400-405`): when `BITBUCKET_CLONE_DIR` + * is set, that runner disallows named volumes and `--security-opt`, so Go + * drops named-volume binds and clears `SecurityOpt` before starting any + * container. Mirrors `legacyApplyBitbucketDockerFilter` + * (`legacy-docker-run.args.ts`) for the `docker create` shape — e.g. the + * Postgres container's `_db:/var/lib/postgresql/data` named-volume + * bind is dropped while a bind-mount stays; Vector's non-root Docker-socket + * bind mount (already a bind mount, not a named volume) is unaffected either + * way, but its `SecurityOpt: ["label:disable"]` is cleared. + * + * `volumesFrom` and `tmpfs` are untouched: Go's Bitbucket branch only ever + * reassigns `hostConfig.Binds` and clears `hostConfig.SecurityOpt` + * (`docker.go:401-405`) — it does not touch `VolumesFrom` or `Tmpfs`. + */ +export function legacyApplyBitbucketStartContainerFilter( + spec: LegacyStartContainerSpec, + isBitbucket: boolean, +): LegacyStartContainerSpec { + if (!isBitbucket) return spec; + return { + ...spec, + binds: spec.binds.filter((bind) => legacyIsBindMountSource(legacyBindMountSpecSource(bind))), + securityOpt: [], + }; +} diff --git a/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts new file mode 100644 index 0000000000..c9d12d97cf --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, test } from "vitest"; + +import { + buildLegacyStartContainerCreateArgs, + legacyApplyBitbucketStartContainerFilter, + legacyBuildHealthCmdArg, + legacyIsDockerClientEnvKey, + type LegacyStartContainerSpec, +} from "./docker-create-args.ts"; + +// Mirrors the Logflare/analytics container (`start.go:350-394`): the fullest +// worked example in the Go source — Hostname, Entrypoint+Cmd, exec-form +// Healthcheck with StartPeriod, ExposedPorts alongside a matching +// PortBinding, RestartPolicy, and network aliases. +const full: LegacyStartContainerSpec = { + image: "supabase/logflare:1.0.0", + containerName: "supabase_analytics_proj", + hostname: "127.0.0.1", + env: { DB_PASSWORD: "super-secret", DB_HOSTNAME: "supabase_db_proj" }, + entrypoint: "sh", + cmd: ["-c", "./logflare start"], + binds: ["/host/gcloud.json:/opt/app/gcloud.json"], + volumesFrom: ["supabase_storage_proj"], + tmpfs: { "/docker-entrypoint-initdb.d": "" }, + ports: [{ hostPort: "4000", containerPort: "4000" }], + exposedPorts: [{ containerPort: "4000" }], + healthcheck: { + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + securityOpt: ["label:disable"], + extraHosts: ["host.docker.internal:host-gateway"], + networkId: "supabase_network_proj", + networkAliases: ["analytics"], + labels: { + "com.supabase.cli.project": "proj", + "com.docker.compose.project": "proj", + }, +}; + +describe("buildLegacyStartContainerCreateArgs", () => { + test("assembles full-option argv in the documented fixed order", () => { + expect(buildLegacyStartContainerCreateArgs(full)).toEqual([ + "create", + "--name", + "supabase_analytics_proj", + "--hostname", + "127.0.0.1", + "-e", + "DB_PASSWORD", + "-e", + "DB_HOSTNAME", + "-v", + "/host/gcloud.json:/opt/app/gcloud.json", + "--volumes-from", + "supabase_storage_proj", + "--tmpfs", + "/docker-entrypoint-initdb.d", + "-p", + "4000:4000", + "--expose", + "4000", + "--health-cmd", + "curl -sSfL --head -o /dev/null http://127.0.0.1:4000/health", + "--health-interval", + "10s", + "--health-timeout", + "2s", + "--health-retries", + "3", + "--health-start-period", + "10s", + "--restart", + "unless-stopped", + "--security-opt", + "label:disable", + "--add-host", + "host.docker.internal:host-gateway", + "--network", + "supabase_network_proj", + "--network-alias", + "analytics", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "--entrypoint", + "sh", + "supabase/logflare:1.0.0", + "-c", + "./logflare start", + ]); + }); + + test("emits only required flags for the minimal-options case", () => { + const minimal: LegacyStartContainerSpec = { + image: "supabase/postgres-meta:v1", + containerName: "supabase_pg_meta_proj", + env: {}, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + expect(buildLegacyStartContainerCreateArgs(minimal)).toEqual([ + "create", + "--name", + "supabase_pg_meta_proj", + "--network", + "supabase_network_proj", + "supabase/postgres-meta:v1", + ]); + }); + + test("never serializes env values into argv (CWE-214: secrets must not leak to ps)", () => { + const args = buildLegacyStartContainerCreateArgs(full); + expect(args).toContain("DB_PASSWORD"); + expect(args.some((a) => a.includes("super-secret"))).toBe(false); + // Every -e argument is a bare key: no '=' anywhere in an -e value. + const envValues = args.flatMap((a, i) => (args[i - 1] === "-e" ? [a] : [])); + expect(envValues.every((v) => !v.includes("="))).toBe(true); + }); + + test("emits DOCKER_HOST inline as -e KEY=value, not key-only, since it's not a secret (Vector's tcp/npipe daemon host)", () => { + const spec: LegacyStartContainerSpec = { + image: "timberio/vector:0.36.0-alpine", + containerName: "supabase_vector_proj", + env: { DOCKER_HOST: "http://host.docker.internal:2375", API_KEY: "super-secret" }, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + const args = buildLegacyStartContainerCreateArgs(spec); + expect(args).toContain("-e"); + const dockerHostIndex = args.indexOf("DOCKER_HOST=http://host.docker.internal:2375"); + expect(dockerHostIndex).toBeGreaterThan(-1); + expect(args[dockerHostIndex - 1]).toBe("-e"); + // The genuine secret alongside it must still stay key-only. + expect(args).toContain("API_KEY"); + expect(args.some((a) => a.includes("super-secret"))).toBe(false); + }); + + test("legacyIsDockerClientEnvKey recognizes Docker/Podman client env vars and nothing else", () => { + expect(legacyIsDockerClientEnvKey("DOCKER_HOST")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_TLS_VERIFY")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_CERT_PATH")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_CONTEXT")).toBe(true); + expect(legacyIsDockerClientEnvKey("DOCKER_API_VERSION")).toBe(true); + expect(legacyIsDockerClientEnvKey("DB_PASSWORD")).toBe(false); + }); + + test("omits the protocol suffix for tcp ports and adds /udp when specified", () => { + const spec: LegacyStartContainerSpec = { + image: "img", + containerName: "c", + env: {}, + binds: [], + networkId: "net", + labels: {}, + ports: [ + { hostPort: "53", containerPort: "53", protocol: "udp" }, + { hostPort: "80", containerPort: "80" }, + { hostPort: "81", containerPort: "81", protocol: "tcp" }, + ], + exposedPorts: [{ containerPort: "9999" }, { containerPort: "9998", protocol: "udp" }], + }; + const args = buildLegacyStartContainerCreateArgs(spec); + expect(args).toEqual( + expect.arrayContaining([ + "-p", + "53:53/udp", + "-p", + "80:80", + "-p", + "81:81", + "--expose", + "9999", + "--expose", + "9998/udp", + ]), + ); + }); + + test("emits --tmpfs with :options only when options are non-empty", () => { + const spec: LegacyStartContainerSpec = { + image: "img", + containerName: "c", + env: {}, + binds: [], + networkId: "net", + labels: {}, + tmpfs: { "/tmp/bare": "", "/tmp/opts": "rw,size=100m" }, + }; + const args = buildLegacyStartContainerCreateArgs(spec); + expect(args).toEqual( + expect.arrayContaining(["--tmpfs", "/tmp/bare", "--tmpfs", "/tmp/opts:rw,size=100m"]), + ); + }); + + test("emits cmd tokens after the image even when entrypoint is absent (Pooler: start.go:1234-1237)", () => { + const spec: LegacyStartContainerSpec = { + image: "supabase/supavisor:2.0.0", + containerName: "supabase_pooler_proj", + env: {}, + binds: [], + networkId: "net", + labels: {}, + cmd: ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"], + }; + const args = buildLegacyStartContainerCreateArgs(spec); + expect(args).not.toContain("--entrypoint"); + const imageIdx = args.indexOf("supabase/supavisor:2.0.0"); + expect(args.slice(imageIdx)).toEqual([ + "supabase/supavisor:2.0.0", + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/server", + ]); + }); + + test("omits health flags entirely when healthcheck is absent (Kong/PostgREST: start.go:975)", () => { + const spec: LegacyStartContainerSpec = { + image: "kong:3", + containerName: "supabase_kong_proj", + env: {}, + binds: [], + networkId: "net", + labels: {}, + }; + const args = buildLegacyStartContainerCreateArgs(spec); + expect(args.some((a) => a.startsWith("--health"))).toBe(false); + }); + + test("never reads spec.secretFiles — the pure builder emits nothing from it (container-lifecycle.ts alone stages it into a real bind, CWE-214/522)", () => { + const spec: LegacyStartContainerSpec = { + image: "kong:3", + containerName: "supabase_kong_proj", + env: {}, + binds: ["/host/other.txt:/etc/other.txt"], + networkId: "net", + labels: {}, + secretFiles: [{ containerPath: "/etc/secret.yml", content: "top-secret-content" }], + }; + const args = buildLegacyStartContainerCreateArgs(spec); + expect(args.some((a) => a.includes("top-secret-content"))).toBe(false); + expect(args.some((a) => a.includes("/etc/secret.yml"))).toBe(false); + // Only the spec's own `binds` entries are ever emitted — secretFiles contributes nothing. + expect(args.filter((a) => a === "-v")).toHaveLength(1); + }); +}); + +describe("legacyBuildHealthCmdArg", () => { + test("forwards CMD-SHELL scripts verbatim, with no quoting applied", () => { + const script = `node --eval="fetch('http://127.0.0.1:8080/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`; + expect(legacyBuildHealthCmdArg(["CMD-SHELL", script])).toBe(script); + }); + + test("shell-quotes each exec-form argument and joins with spaces", () => { + expect( + legacyBuildHealthCmdArg([ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/health", + ]), + ).toBe("curl -sSfL --head -o /dev/null http://127.0.0.1:4000/health"); + }); + + test("quotes an exec-form argument containing spaces so the container shell re-splits it back to one token", () => { + expect(legacyBuildHealthCmdArg(["CMD", "echo", "hello world"])).toBe("echo 'hello world'"); + }); + + test("escapes an embedded single quote using the '\\'' POSIX technique", () => { + expect(legacyBuildHealthCmdArg(["CMD", "echo", "it's here"])).toBe("echo 'it'\\''s here'"); + }); +}); + +describe("legacyApplyBitbucketStartContainerFilter", () => { + const dbLike: LegacyStartContainerSpec = { + image: "supabase/postgres:15", + containerName: "supabase_db_proj", + env: {}, + binds: ["supabase_db_proj:/var/lib/postgresql/data", "/repo/backup.sql:/etc/backup.sql:ro"], + networkId: "net", + labels: {}, + securityOpt: ["label:disable"], + tmpfs: { "/docker-entrypoint-initdb.d": "" }, + volumesFrom: ["supabase_storage_proj"], + }; + + test("passes the spec through unchanged outside Bitbucket", () => { + expect(legacyApplyBitbucketStartContainerFilter(dbLike, false)).toBe(dbLike); + }); + + test("drops named-volume binds and clears security-opt under Bitbucket, leaving tmpfs/volumesFrom untouched", () => { + const filtered = legacyApplyBitbucketStartContainerFilter(dbLike, true); + expect(filtered.binds).toEqual(["/repo/backup.sql:/etc/backup.sql:ro"]); + expect(filtered.securityOpt).toEqual([]); + expect(filtered.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + expect(filtered.volumesFrom).toEqual(["supabase_storage_proj"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.ts b/apps/cli/src/legacy/commands/start/lib/health-check.ts new file mode 100644 index 0000000000..e9f9d823f0 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/health-check.ts @@ -0,0 +1,298 @@ +/** + * Port of Go's `WaitForHealthyService`/`IsServiceReady` + * (`apps/cli-go/internal/db/start/start.go:192-231`, + * `apps/cli-go/internal/status/status.go:147-168`): a single shared probe + * across every still-unhealthy started container, on a 1-second constant + * backoff, for up to `timeoutSeconds` retries (Go's default `serviceTimeout = + * 30 * time.Second`) — NOT independent per-container timers. Each tick probes + * every still-unhealthy container, narrows the "still watching" set to just + * the ones that failed this round (a healthy container stops being probed), + * and only the final timeout's failures surface to the caller. + */ + +import { Data, Effect, Schedule, Stream } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyInspectContainerState } from "../../../shared/legacy-docker-lifecycle.ts"; +import { legacyKongAuthHeaders } from "../../../shared/legacy-kong-auth.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** Go's `serviceTimeout` (`apps/cli-go/internal/start/start.go:161`). */ +const LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS = 30; + +/** + * Go's `healthProbeTimeout` (`apps/cli-go/internal/status/status.go:209`): caps + * a single HTTP readiness probe so a hung response cannot stall the + * surrounding retry loop. + */ +const LEGACY_HTTP_PROBE_TIMEOUT_SECONDS = 10; + +/** `apps/cli-go/internal/status/status.go:161` — PostgREST does not support native Docker healthchecks. */ +const LEGACY_POSTGREST_READY_PATH = "/rest-admin/v1/ready"; + +/** + * `apps/cli-go/internal/status/status.go:163-166` — Edge Runtime bypasses its + * native Docker healthcheck too ("native health check logs too much + * hyper::Error(IncompleteMessage)"), through the exact same + * {@link legacyCheckHttpReady}/Kong-gateway path as PostgREST, just its own + * path and container id. Go's `checkHTTPHead` even shares one lazily-built + * `healthClient` across both call sites (`status.go:202-219`) — the closest + * equivalent here is {@link LegacyHealthCheckPostgrestGateway} being reused + * as-is (name notwithstanding — its shape is generic, not + * PostgREST-specific) for both {@link LegacyWaitForHealthyServicesOptions.postgrest} + * and {@link LegacyWaitForHealthyServicesOptions.edgeRuntime}. + */ +const LEGACY_EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; + +/** Identifies a single container's readiness failure this round. */ +export interface LegacyHealthCheckFailure { + readonly containerId: string; + readonly reason: string; +} + +/** Internal-only: one probe round's failures, narrowing which containers are still watched next round. */ +class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ + readonly failures: ReadonlyArray; +}> {} + +/** + * The retry loop's final, and only surfaced, failure — mirrors Go returning + * `errors.Join(errHealth...)` from the last failed `probe()` call once + * `backoff.Retry` gives up (`start.go:210-214`). + */ +export class LegacyHealthCheckTimeoutError extends Data.TaggedError( + "LegacyHealthCheckTimeoutError", +)<{ + readonly message: string; + readonly unhealthy: ReadonlyArray; +}> {} + +/** + * PostgREST's local Kong gateway coordinates, mirroring Go's + * `fetcher.NewServiceGateway(utils.Config.Api.ExternalUrl, + * utils.Config.Auth.SecretKey.Value, ...)` (`status.go:213-218`). TLS/CA trust + * for a local https gateway is the caller's responsibility when composing the + * `HttpClient.HttpClient` layer this module requires — same split as + * `legacy-storage-gateway.ts`/`legacyStorageGatewayFetch`. + * + * `start.command.ts` composes the `HttpClient.HttpClient` this module + * requires via `legacyHttpClientLayer` (itself `FetchHttpClient`-backed, and + * on its own CA-unaware; see that layer's own header) — the same layer + * `db reset`/`seed buckets` compose for the equivalent gateway calls. + * `start.handler.ts` layers a CA-trusting override on top of that: when + * `api.tls.enabled`, `apiExternalUrl` is `https://` against Kong's + * self-signed local cert (`KONG_LOCAL_CA_CERT`, or a validated + * `api.tls.cert_path` override), so before calling + * {@link legacyWaitForHealthyServices} it resolves that same CA via + * `legacy-storage-credentials.ts`'s `legacyResolveStorageCredentials` (the + * mechanism `seed buckets`/`storage`/`db reset` already use) and, when a + * local CA resolves, overrides `FetchHttpClient.Fetch` with + * `legacyStorageGatewayFetch` around the health-check call via + * `Effect.provideService`. That override only takes effect against a + * `FetchHttpClient`-backed `HttpClient.HttpClient` — exactly what + * `legacyHttpClientLayer` provides — so a stack started with + * `[api.tls] enabled = true` now gets a `legacyCheckHttpReady` probe that + * trusts the local Kong CA instead of exhausting `legacyWaitForHealthyServices`'s + * full 30s budget on a TLS verification failure. + */ +export interface LegacyHealthCheckPostgrestGateway { + readonly containerId: string; + readonly apiExternalUrl: string; + readonly secretKey: string; +} + +export interface LegacyWaitForHealthyServicesOptions { + readonly timeoutSeconds?: number; + readonly postgrest?: LegacyHealthCheckPostgrestGateway; + /** See {@link LEGACY_EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ + readonly edgeRuntime?: LegacyHealthCheckPostgrestGateway; +} + +/** + * Go's `assertContainerHealthy` (`status.go:147-156`), reused verbatim via + * {@link legacyInspectContainerState} — the same primitive `status.handler.ts` + * already uses for the exact same not-running/not-ready gating. + */ +function legacyCheckContainerReady( + spawner: Spawner, + containerId: string, +): Effect.Effect { + return legacyInspectContainerState(spawner, containerId).pipe( + Effect.mapError((cause) => cause.message), + Effect.flatMap((state) => { + if (!state.running) { + return Effect.fail(`container is not running: ${state.status}`); + } + if (state.health !== undefined && state.health !== "healthy") { + return Effect.fail(`container is not ready: ${state.health}`); + } + return Effect.void; + }), + ); +} + +/** + * Go's `checkHTTPHead` (`status.go:211-229`): an HTTP HEAD through the local + * Kong gateway, expecting exactly 200. Bypasses the Docker healthcheck + * entirely — PostgREST "does not support native health checks" + * (`status.go:159-161`). + */ +function legacyCheckHttpReady( + gateway: LegacyHealthCheckPostgrestGateway, + path: string, +): Effect.Effect { + return Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const request = HttpClientRequest.head(`${gateway.apiExternalUrl}${path}`).pipe( + HttpClientRequest.setHeaders(legacyKongAuthHeaders(gateway.secretKey)), + ); + const response = yield* httpClient.execute(request).pipe( + Effect.timeout(`${LEGACY_HTTP_PROBE_TIMEOUT_SECONDS} seconds`), + Effect.mapError((cause) => String(cause)), + ); + if (response.status !== 200) { + return yield* Effect.fail(`unexpected status ${response.status}`); + } + }); +} + +/** + * Go's `DockerStreamLogsOnce` (`apps/cli-go/internal/utils/docker.go:593-606`) + * via `docker logs `, teed to this process's stderr — best-effort: a + * failure to stream logs must never mask the timeout error it was printed + * alongside, so every failure here is swallowed. + */ +function legacyStreamContainerLogsOnce(spawner: Spawner, containerId: string): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawnContainerCli(spawner, ["logs", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + yield* Effect.all( + [ + Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + globalThis.process.stderr.write(chunk); + }), + ), + Stream.runForEach(handle.stderr, (chunk) => + Effect.sync(() => { + globalThis.process.stderr.write(chunk); + }), + ), + ], + { concurrency: "unbounded" }, + ); + yield* handle.exitCode; + }), + ).pipe(Effect.orElseSucceed(() => undefined)); +} + +/** Go's `fmt.Fprintln(os.Stderr, containerId, "container logs:")` (`start.go:218`) + the log dump itself. */ +function legacyDumpContainerLogs(spawner: Spawner, containerId: string): Effect.Effect { + return Effect.gen(function* () { + yield* Effect.sync(() => { + globalThis.process.stderr.write(`${containerId} container logs:\n`); + }); + yield* legacyStreamContainerLogsOnce(spawner, containerId); + }); +} + +/** + * Waits for every container in `containerIds` to become ready, mirroring + * Go's `WaitForHealthyService(ctx, timeout, started...)`. Resolves once all + * are ready; fails with {@link LegacyHealthCheckTimeoutError} (carrying every + * still-unhealthy container's last-seen reason) once the retry budget is + * exhausted. The caller (`start.handler.ts`) decides whether + * `--ignore-health-check` turns that failure into a warning instead of a hard + * exit — this module only implements the polling contract. + */ +export function legacyWaitForHealthyServices( + spawner: Spawner, + containerIds: ReadonlyArray, + opts: LegacyWaitForHealthyServicesOptions = {}, +): Effect.Effect { + const timeoutSeconds = opts.timeoutSeconds ?? LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS; + const postgrest = opts.postgrest; + const edgeRuntime = opts.edgeRuntime; + + const checkOne = (containerId: string): Effect.Effect => { + if (postgrest !== undefined && containerId === postgrest.containerId) { + return legacyCheckHttpReady(postgrest, LEGACY_POSTGREST_READY_PATH); + } + if (edgeRuntime !== undefined && containerId === edgeRuntime.containerId) { + return legacyCheckHttpReady(edgeRuntime, LEGACY_EDGE_RUNTIME_READY_PATH); + } + return legacyCheckContainerReady(spawner, containerId); + }; + + return Effect.gen(function* () { + let stillWatching = containerIds; + + // Mirrors Go's closure-captured `started` slice + // (`db/start/start.go:200-212`): each round narrows `stillWatching` to + // just the containers that failed, so a container that becomes healthy + // mid-run stops being probed on later rounds. + const probe: Effect.Effect = + Effect.gen(function* () { + const outcomes = yield* Effect.forEach( + stillWatching, + (containerId) => + checkOne(containerId).pipe( + Effect.match({ + onFailure: (reason): LegacyHealthCheckFailure | undefined => ({ + containerId, + reason, + }), + onSuccess: (): LegacyHealthCheckFailure | undefined => undefined, + }), + ), + { concurrency: "unbounded" }, + ); + const failures = outcomes.filter( + (outcome): outcome is LegacyHealthCheckFailure => outcome !== undefined, + ); + stillWatching = failures.map((failure) => failure.containerId); + if (failures.length > 0) { + return yield* Effect.fail(new LegacyHealthCheckProbeError({ failures })); + } + }); + + // `backoff.WithMaxRetries(backoff.NewConstantBackOff(time.Second), + // uint64(timeout.Seconds()))` (`db/start/start.go:192-198`): a 1-second + // constant delay, capped at `timeoutSeconds` retries after the initial + // attempt (~`timeoutSeconds` further seconds elapsed on total failure). + const schedule = Schedule.spaced("1 seconds").pipe( + Schedule.both(Schedule.recurs(timeoutSeconds)), + ); + + yield* probe.pipe( + Effect.retry(schedule), + Effect.catch((probeError) => + Effect.gen(function* () { + // Go skips this dump on context cancellation (`start.go:215`, + // `!errors.Is(err, context.Canceled)`) — an interrupted fiber never + // reaches this `Effect.catch` handler at all, so no separate check + // is needed here. + yield* Effect.forEach(probeError.failures, (failure) => + legacyDumpContainerLogs(spawner, failure.containerId), + ); + return yield* Effect.fail( + new LegacyHealthCheckTimeoutError({ + message: probeError.failures + .map((failure) => `${failure.containerId}: ${failure.reason}`) + .join("\n"), + unhealthy: probeError.failures, + }), + ); + }), + ), + ); + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts new file mode 100644 index 0000000000..ed0d287421 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as TestClock from "effect/testing/TestClock"; + +import { + LegacyHealthCheckTimeoutError, + legacyWaitForHealthyServices, + type LegacyHealthCheckPostgrestGateway, +} from "./health-check.ts"; + +/** + * A spawner that answers `docker container inspect`/`docker logs` calls. + * `inspectResponse` is called once per `(containerId, callIndex)` pair — the + * `callIndex` (0-based, per container) lets a test script a container's + * health across successive polling rounds. `docker logs` calls (the + * timeout-path debug dump) always succeed with empty output. + */ +function mockHealthSpawner(inspectResponse: (containerId: string, callIndex: number) => string) { + const counts = new Map(); + const encoder = new TextEncoder(); + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + + let stdout = ""; + if (args[0] === "container" && args[1] === "inspect") { + const containerId = args[2] ?? ""; + const callIndex = counts.get(containerId) ?? 0; + counts.set(containerId, callIndex + 1); + stdout = inspectResponse(containerId, callIndex); + } + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(stdout.length > 0 ? [encoder.encode(stdout)] : []), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +const runningHealthy = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "healthy" }, +}); +const runningStarting = JSON.stringify({ + Status: "running", + Running: true, + Health: { Status: "starting" }, +}); +const notRunning = JSON.stringify({ Status: "exited", Running: false }); + +/** + * `legacyWaitForHealthyServices` structurally requires `HttpClient.HttpClient` + * (only exercised on the PostgREST HTTP-HEAD branch) — every test provides + * some `HttpClient.HttpClient`, and this one fails loudly if a + * container-only-health-check test ever calls it unexpectedly. + */ +const unusedHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("HttpClient should not be called for a plain container check")), +); + +describe("legacyWaitForHealthyServices", () => { + it.effect( + "polls on a 1-second backoff until the container reports healthy, without waiting a full timeout", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner((_id, callIndex) => + callIndex === 0 ? runningStarting : runningHealthy, + ); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_kong_proj"], { + timeoutSeconds: 30, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isSuccess(exit)).toBe(true); + const inspectCalls = mock.spawned.filter( + (args) => args[0] === "container" && args[1] === "inspect", + ); + expect(inspectCalls).toHaveLength(2); + }), + ); + + it.effect( + "stops probing a container once it becomes healthy, and only reports the still-unhealthy one on timeout", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner((containerId) => + containerId === "supabase_kong_proj" ? runningHealthy : notRunning, + ); + + const fiber = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_kong_proj", "supabase_rest_proj"], + { timeoutSeconds: 2 }, + ).pipe(Effect.provide(unusedHttpClientLayer), Effect.forkChild({ startImmediately: true })); + + // 2 retries after the initial attempt (Go's `WithMaxRetries(..., timeout.Seconds())`). + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + + const kongCalls = mock.spawned.filter( + (args) => + args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_kong_proj", + ); + const restCalls = mock.spawned.filter( + (args) => + args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_rest_proj", + ); + // The healthy container is probed exactly once, then narrowed out of + // the "still watching" set — the unhealthy one is probed on every round. + expect(kongCalls).toHaveLength(1); + expect(restCalls).toHaveLength(3); + }), + ); + + it.effect( + "fails with LegacyHealthCheckTimeoutError carrying only the still-unhealthy container's reason", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_rest_proj", reason: "container is not running: exited" }, + ]); + expect(error.message).toBe("supabase_rest_proj: container is not running: exited"); + }), + ); + + it.effect("dumps container logs to stderr on a genuine timeout", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => notRunning); + const writes: Array = []; + const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); + globalThis.process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof globalThis.process.stderr.write; + + try { + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + }).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + yield* Fiber.await(fiber); + } finally { + globalThis.process.stderr.write = originalWrite; + } + + expect(writes.some((chunk) => chunk.includes("supabase_rest_proj container logs:"))).toBe( + true, + ); + expect( + mock.spawned.some((args) => args[0] === "logs" && args[1] === "supabase_rest_proj"), + ).toBe(true); + }), + ); + + describe("PostgREST HTTP-HEAD readiness", () => { + function postgrestGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { + return { + containerId: "supabase_rest_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey, + }; + } + + function httpLayer(status: number, expectHeaders: (headers: Record) => void) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + expectHeaders({ ...request.headers }); + expect(request.method).toBe("HEAD"); + expect(request.url).toBe("http://127.0.0.1:54321/rest-admin/v1/ready"); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status })), + ); + }), + ); + } + + it.effect("bypasses the Docker healthcheck and succeeds on a 200 HEAD response", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(200, (headers) => { + expect(headers["apikey"]).toBe("sb_secret_local"); + expect(headers["authorization"]).toBeUndefined(); + }); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("sb_secret_local"), + }).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + // Never falls back to the Docker healthcheck for PostgREST. + expect(mock.spawned.some((args) => args[0] === "container" && args[1] === "inspect")).toBe( + false, + ); + }), + ); + + it.effect("sends both apikey and Authorization headers for a JWT secret key", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(200, (headers) => { + expect(headers["apikey"]).toBe("ey.jwt.key"); + expect(headers["authorization"]).toBe("Bearer ey.jwt.key"); + }); + + const exit = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("ey.jwt.key"), + }).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); + + it.effect("retries and eventually times out when PostgREST never returns 200", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(503, () => {}); + + const fiber = yield* legacyWaitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("sb_secret_local"), + }).pipe(Effect.provide(layer), Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_rest_proj", reason: "unexpected status 503" }, + ]); + }), + ); + }); + + describe("Edge Runtime HTTP-HEAD readiness", () => { + function edgeRuntimeGateway(secretKey: string): LegacyHealthCheckPostgrestGateway { + return { + containerId: "supabase_edge_runtime_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey, + }; + } + + function httpLayer(status: number, expectPath: string) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.method).toBe("HEAD"); + expect(request.url).toBe(`http://127.0.0.1:54321${expectPath}`); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status })), + ); + }), + ); + } + + it.effect("bypasses the Docker healthcheck and succeeds on a 200 HEAD response", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(200, "/functions/v1/_internal/health"); + + const exit = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_edge_runtime_proj"], + { + timeoutSeconds: 1, + edgeRuntime: edgeRuntimeGateway("sb_secret_local"), + }, + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + // Never falls back to the Docker healthcheck for Edge Runtime. + expect(mock.spawned.some((args) => args[0] === "container" && args[1] === "inspect")).toBe( + false, + ); + }), + ); + + it.effect("retries and eventually times out when Edge Runtime never returns 200", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = httpLayer(503, "/functions/v1/_internal/health"); + + const fiber = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_edge_runtime_proj"], + { + timeoutSeconds: 1, + edgeRuntime: edgeRuntimeGateway("sb_secret_local"), + }, + ).pipe(Effect.provide(layer), Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyHealthCheckTimeoutError); + expect(error.unhealthy).toEqual([ + { containerId: "supabase_edge_runtime_proj", reason: "unexpected status 503" }, + ]); + }), + ); + + it.effect( + "probes PostgREST and Edge Runtime on their own paths, and every other container via Docker", + () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningHealthy); + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + expect(["/rest-admin/v1/ready", "/functions/v1/_internal/health"]).toContain( + url.pathname, + ); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ); + }), + ); + + const exit = yield* legacyWaitForHealthyServices( + mock.spawner, + ["supabase_rest_proj", "supabase_edge_runtime_proj", "supabase_kong_proj"], + { + timeoutSeconds: 1, + postgrest: { + containerId: "supabase_rest_proj", + apiExternalUrl: "http://127.0.0.1:54321", + secretKey: "sb_secret_local", + }, + edgeRuntime: edgeRuntimeGateway("sb_secret_local"), + }, + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(true); + expect( + mock.spawned.some( + (args) => + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_kong_proj", + ), + ).toBe(true); + expect( + mock.spawned.some( + (args) => + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_rest_proj", + ), + ).toBe(false); + expect( + mock.spawned.some( + (args) => + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_edge_runtime_proj", + ), + ).toBe(false); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/image-prepull.ts b/apps/cli/src/legacy/commands/start/lib/image-prepull.ts new file mode 100644 index 0000000000..bf43bd04ee --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/image-prepull.ts @@ -0,0 +1,95 @@ +/** + * Port of Go's `ensureImagesCached` (`apps/cli-go/internal/start/start.go:225-262`): + * guarantees every image `supabase start` needs is resolved/pulled into the + * local Docker cache BEFORE any container is created, using the same + * multi-registry fallback as the per-container start path + * (`legacyMakeDockerImageResolver`). + * + * Go's caller (`internal/start/start.go:264-291`, `run`) also runs a + * best-effort `docker-compose`-based pre-pull first + * (`pullImagesUsingCompose`) and treats `ensureImagesCached` as the hard-failing + * backstop that catches whatever the compose pre-pull's `IgnoreFailures` step + * skipped. This port does not implement docker-compose integration anywhere + * (an intentional architecture decision for this port), so + * {@link legacyEnsureImagesCached} is the ONLY image pre-pull step here, not a + * backstop for a separate best-effort pass — every image must resolve through + * this call before any container starts. + */ + +import { Data, Effect, Result } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyMakeDockerImageResolver } from "../../../shared/legacy-docker-image-resolve.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../../../shared/legacy-docker-suggest.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * One or more images failed to resolve/pull from every registry candidate. + * Mirrors Go's `errors.Join(result...)` (`start.go:257`): the message + * aggregates every failed image's own error rather than surfacing only the + * first failure, so a caller can see every broken image in one report. + */ +export class LegacyImagePrepullError extends Data.TaggedError("LegacyImagePrepullError")<{ + readonly message: string; +}> {} + +/** + * Resolves every image in `images` concurrently (Go's `utils.WaitAll` — + * unbounded goroutines) via the shared registry-fallback resolver, and returns + * a map from the ORIGINAL image reference to the resolved image URL a caller + * must use to reference that image afterward (e.g. as + * `LegacyStartContainerSpec.image`) — resolving the same image twice would be + * wasteful and could, in theory, land on a different registry candidate on a + * second, independent call. + * + * `images` is deduped here (Go's `seen := map[string]struct{}{}`, + * `start.go:238-249`) — callers are not expected to have already deduped their + * service image list. + */ +export function legacyEnsureImagesCached( + spawner: Spawner, + images: ReadonlyArray, + projectEnvValues?: Readonly>, +): Effect.Effect, LegacyImagePrepullError> { + const uniqueImages = [...new Set(images)]; + const resolveImage = legacyMakeDockerImageResolver(spawner, projectEnvValues); + + return Effect.gen(function* () { + const results = yield* Effect.all( + uniqueImages.map((image) => resolveImage(image).pipe(Effect.result)), + { concurrency: "unbounded" }, + ); + + const resolved = new Map(); + const failures: Array = []; + for (const [index, image] of uniqueImages.entries()) { + const result = results[index]; + if (result === undefined || Result.isFailure(result)) { + failures.push(result === undefined ? `${image}: unknown error` : result.failure.message); + continue; + } + resolved.set(image, result.success); + } + + if (failures.length > 0) { + // Go sets the install hint once, sequentially, after the concurrent + // resolve finishes (`SuggestDockerInstallIfConnectionFailed`, + // `start.go:254-259`) rather than from inside the resolver itself, where + // concurrent goroutines would race on the shared `CmdSuggestion` global. + // There is no such global here, so the hint is appended directly onto + // the joined message instead. + const hint = failures.some(legacyIsDockerDaemonUnreachable) + ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` + : ""; + return yield* Effect.fail( + new LegacyImagePrepullError({ message: `${failures.join("\n")}${hint}` }), + ); + } + + return resolved; + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts new file mode 100644 index 0000000000..8aa1cd358a --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Ref, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { LegacyImagePrepullError, legacyEnsureImagesCached } from "./image-prepull.ts"; + +/** Matches the standing `mockSpawner` shape in `legacy-docker-lifecycle.unit.test.ts`, generalized to a per-call handler so each argv can respond differently (needed for "some images cached, others not"). */ +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const encoder = new TextEncoder(); + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +describe("legacyEnsureImagesCached", () => { + it.live("dedupes images before resolving, returning original ref -> resolved URL", () => { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") { + const image = args[2]; + const cached = + image === "public.ecr.aws/supabase/postgres:15" || + image === "public.ecr.aws/supabase/kong:3"; + return { exitCode: cached ? 0 : 1 }; + } + return { exitCode: 1 }; + }); + + return legacyEnsureImagesCached(mock.spawner, [ + "supabase/postgres:15", + "supabase/kong:3", + "supabase/postgres:15", + ]).pipe( + Effect.map((resolved) => { + expect(resolved).toEqual( + new Map([ + ["supabase/postgres:15", "public.ecr.aws/supabase/postgres:15"], + ["supabase/kong:3", "public.ecr.aws/supabase/kong:3"], + ]), + ); + // One `image inspect` call per UNIQUE image, not one per (duplicated) input entry. + const inspectCalls = mock.spawned.filter( + (call) => call[0] === "image" && call[1] === "inspect", + ); + expect(inspectCalls).toHaveLength(2); + }), + ); + }); + + it.live("resolves every image concurrently rather than one at a time", () => + Effect.gen(function* () { + const started = yield* Ref.make(0); + const bothStarted = yield* Deferred.make(); + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + if (args[0] === "image" && args[1] === "inspect") { + const count = yield* Ref.updateAndGet(started, (n) => n + 1); + if (count < 2) { + // A sequential (non-concurrent) implementation would never let the + // second image's `image inspect` call start until this one + // returns, so awaiting here would hang forever — proving + // concurrency is what lets this test complete at all. + yield* Deferred.await(bothStarted); + } else { + yield* Deferred.succeed(bothStarted, undefined); + } + } + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + const resolved = yield* legacyEnsureImagesCached(spawner, ["supabase/a:1", "supabase/b:1"]); + expect(resolved.size).toBe(2); + }), + ); + + it.live("aggregates every failed image's message into one combined error", () => { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "pull") return { exitCode: 1, stderr: `no such image: ${args[1]}\n` }; + return { exitCode: 1 }; + }); + + return legacyEnsureImagesCached(mock.spawner, ["supabase/a:1", "supabase/b:1"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyImagePrepullError); + expect(error.message).toContain("supabase/a:1"); + expect(error.message).toContain("supabase/b:1"); + }), + ); + }); + + it.live( + "appends the install hint once when a failure indicates the daemon is unreachable", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "pull") { + return { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", + }; + } + return { exitCode: 1 }; + }); + + return legacyEnsureImagesCached(mock.spawner, ["supabase/a:1"]).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toContain("Docker Desktop is a prerequisite for local development"); + }), + ); + }, + ); + + it.live("resolves an empty map for an empty image list without spawning anything", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyEnsureImagesCached(mock.spawner, []).pipe( + Effect.map((resolved) => { + expect(resolved.size).toBe(0); + expect(mock.spawned).toHaveLength(0); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts new file mode 100644 index 0000000000..7f7dd46b06 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts @@ -0,0 +1,56 @@ +/** + * The fixed, in-Docker-network Postgres address every `start`-created service + * container (Realtime, PostgREST, Storage) reaches Postgres at, ported from + * Go's `dbConfig := pgconn.Config{Host: utils.DbId, Port: 5432, User: + * "postgres", Password: utils.Config.Db.Password, Database: "postgres"}` + * (`apps/cli-go/internal/start/start.go:66-72`, threaded through `run()` as + * `dbConfig`). + * + * This is deliberately NOT the same value as + * `LegacyLocalConfigValues.dbUrl` (`legacy-local-config-values.ts`): that + * field is `postgresql://postgres:@:/postgres` + * — the HOST-facing address `status`/`stop` print for a user's own `psql` + * client, reachable through Docker's published port mapping. Every + * container-to-container env var in `start.go` instead reaches Postgres via + * `utils.DbId` (the `db` container's own Docker name) on its unpublished, + * always-5432 internal port — the two never share a value except by + * coincidence (`db.port` happening to equal 5432). + * + * Hoisted here (`start/lib/`, the `start` command family's shared root) per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Realtime, + * PostgREST, and Storage's own container-spec builders + * (`start/services/*.service.ts`) all need this exact host/port/password + * derivation. + */ + +/** Go's `dbConfig.Port` literal (`start.go:68`) — always 5432, never the configurable `db.port`. */ +export const LEGACY_START_INTERNAL_DB_PORT = 5432; + +/** Go's `dbConfig.Database` literal (`start.go:71`) — always `"postgres"`, never configurable. */ +export const LEGACY_START_INTERNAL_DB_NAME = "postgres"; + +/** + * Extracts `dbConfig.Password` (Go's `utils.Config.Db.Password`, `db.go:88` — + * `toml:"-"`, never configurable, always the `"postgres"` literal default, + * `pkg/config/config.go:459`) from the already-resolved + * `LegacyLocalConfigValues.dbUrl` rather than re-deriving that default a + * second time — `dbUrl`'s shape + * (`postgresql://postgres:@:/postgres`, + * `legacyResolveLocalConfigValues`) always embeds the exact same password + * value `dbConfig.Password` does, since both are sourced from the same + * `config.Db.Password` field. + */ +export function legacyStartInternalDbPassword(dbUrl: string): string { + return new URL(dbUrl).password; +} + +/** + * `:@:5432/postgres` — the shape of every + * container-to-container Postgres URI `start.go` builds for a specific role + * (PostgREST's `PGRST_DB_URI` as `authenticator`, Storage's `DATABASE_URL` as + * `supabase_storage_admin`, Storage's vector-bucket default `VECTOR_DATABASE_URL` + * as `postgres` — see `appendStorageVectorEnv`, `start.go:1487-1501`). + */ +export function legacyStartInternalDbUrl(role: string, dbHost: string, dbPassword: string): string { + return `postgresql://${role}:${dbPassword}@${dbHost}:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`; +} diff --git a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts new file mode 100644 index 0000000000..25f27fdc1d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; + +import { + LEGACY_START_INTERNAL_DB_NAME, + LEGACY_START_INTERNAL_DB_PORT, + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "./internal-db-connection.ts"; + +describe("legacyStartInternalDbPassword", () => { + test("extracts the password component from a resolved dbUrl", () => { + expect( + legacyStartInternalDbPassword("postgresql://postgres:postgres@127.0.0.1:54322/postgres"), + ).toBe("postgres"); + }); + + test("extracts a non-default password", () => { + expect( + legacyStartInternalDbPassword("postgresql://postgres:super-secret@127.0.0.1:54322/postgres"), + ).toBe("super-secret"); + }); +}); + +describe("legacyStartInternalDbUrl", () => { + test("builds a role-specific internal connection string on the fixed port/database", () => { + expect(legacyStartInternalDbUrl("authenticator", "supabase_db_proj", "postgres")).toBe( + `postgresql://authenticator:postgres@supabase_db_proj:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`, + ); + }); + + test("port and database name are always the fixed internal values", () => { + expect(LEGACY_START_INTERNAL_DB_PORT).toBe(5432); + expect(LEGACY_START_INTERNAL_DB_NAME).toBe("postgres"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts new file mode 100644 index 0000000000..1d1756256b --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts @@ -0,0 +1,25 @@ +/** + * Go's `envOrDefault(key, def string) string` (`start.go:1466-1471`): + * `os.LookupEnv`-if-set-else-default — an env var that is SET but empty is + * used verbatim (unlike `legacy-local-config-values.ts`'s `envOverride`, + * which treats an empty resolved value as unset). `projectEnvValues` mirrors + * that module's own merged (dotenv + ambient shell, ambient-wins) map; + * `??` only skips a `null`/`undefined` operand, never an empty string, so + * this naturally reproduces `LookupEnv`'s "ok if set, even if empty" + * semantics without a separate presence check. No `SUPABASE_` prefix and no + * `env(VAR)` indirection — Go's raw `os.LookupEnv` here bypasses the + * mapstructure decode-hook chain those only apply to. + * + * Hoisted here (`start/lib/`, the `start` command family's shared root) per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Storage's + * vector-bucket env (`services/storage.service.ts`) and Kong's + * `KONG_NGINX_WORKER_PROCESSES` (`services/kong.service.ts`) both need this + * exact env/dotenv-vs-default derivation. + */ +export function legacyEnvOrDefault( + key: string, + def: string, + projectEnvValues: Readonly> | undefined, +): string { + return projectEnvValues?.[key] ?? process.env[key] ?? def; +} diff --git a/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.unit.test.ts new file mode 100644 index 0000000000..44f19d6861 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; + +import { legacyEnvOrDefault } from "./legacy-env-or-default.ts"; + +describe("legacyEnvOrDefault", () => { + test('falls back to the default when unset anywhere (Go\'s "envOrDefault", start.go:1466-1471)', () => { + expect(legacyEnvOrDefault("LEGACY_ENV_OR_DEFAULT_UNSET_KEY", "default", undefined)).toBe( + "default", + ); + }); + + test("prefers a projectEnvValues (dotenv) value over the default", () => { + expect(legacyEnvOrDefault("SOME_KEY", "default", { SOME_KEY: "from-dotenv" })).toBe( + "from-dotenv", + ); + }); + + test("an override that is set but empty is used verbatim, matching os.LookupEnv (not treated as unset)", () => { + expect(legacyEnvOrDefault("SOME_KEY", "default", { SOME_KEY: "" })).toBe(""); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/lib/template-render.ts b/apps/cli/src/legacy/commands/start/lib/template-render.ts new file mode 100644 index 0000000000..de266f9aa9 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/template-render.ts @@ -0,0 +1,131 @@ +/** + * Pure renderer for the Go `text/template` sources transcribed under + * `../templates/`. Mirrors `apps/cli-go/internal/start/start.go`'s use of + * `text/template`: every placeholder is a bare `{{ .FieldName }}` reference + * (no pipelines, functions, or control structures), and every one of the + * three templates rendered here is executed with `Option("missingkey=error")` + * (verified via `grep -n '\.Execute(' apps/cli-go/internal/start/start.go`, + * lines 399, 489, 1203) — so a placeholder with no matching field always + * throws, never silently renders Go's ``. + * + * `custom_nginx.template` is deliberately NOT rendered here: it is not a Go + * `text/template` at all (see the comment on + * `LEGACY_START_CUSTOM_NGINX_TEMPLATE`) and is injected byte-for-byte, with + * its `${{VAR}}` placeholders substituted by Kong itself at container boot. + * Callers needing that file should import `LEGACY_START_CUSTOM_NGINX_TEMPLATE` + * directly from `../templates/custom_nginx.template.ts`. + */ +import { LEGACY_START_KONG_YML_TEMPLATE } from "../templates/kong.yml.ts"; +import { LEGACY_START_POOLER_EXS_TEMPLATE } from "../templates/pooler.exs.ts"; +import { LEGACY_START_VECTOR_YAML_TEMPLATE } from "../templates/vector.yaml.ts"; + +const GO_TEMPLATE_FIELD_PATTERN = /\{\{\s*\.(\w+)\s*\}\}/g; + +/** + * Substitutes every `{{ .FieldName }}` occurrence in `template` with the + * matching value from `fields`, replicating Go's `text/template` rendered + * with `Option("missingkey=error")`: a placeholder referencing a field not + * present in `fields` throws instead of rendering ``. + */ +export function legacyRenderGoTemplate( + template: string, + fields: Readonly>, +): string { + return template.replace(GO_TEMPLATE_FIELD_PATTERN, (_match, fieldName: string) => { + if (!Object.hasOwn(fields, fieldName)) { + throw new Error( + `legacyRenderGoTemplate: template references undefined field ".${fieldName}" (missingkey=error)`, + ); + } + return String(fields[fieldName]); + }); +} + +export interface LegacyStartKongYmlFields { + readonly gotrueId: string; + readonly restId: string; + readonly realtimeId: string; + readonly storageId: string; + readonly studioId: string; + readonly pgmetaId: string; + readonly edgeRuntimeId: string; + readonly logflareId: string; + readonly poolerId: string; + readonly apiHost: string; + readonly apiPort: number; + readonly bearerToken: string; + readonly queryToken: string; +} + +/** Renders `kong.yml` from Go's `kongConfig` struct (`start.go:90-104`). */ +export function legacyRenderStartKongYml(fields: LegacyStartKongYmlFields): string { + return legacyRenderGoTemplate(LEGACY_START_KONG_YML_TEMPLATE, { + GotrueId: fields.gotrueId, + RestId: fields.restId, + RealtimeId: fields.realtimeId, + StorageId: fields.storageId, + StudioId: fields.studioId, + PgmetaId: fields.pgmetaId, + EdgeRuntimeId: fields.edgeRuntimeId, + LogflareId: fields.logflareId, + PoolerId: fields.poolerId, + ApiHost: fields.apiHost, + ApiPort: fields.apiPort, + BearerToken: fields.bearerToken, + QueryToken: fields.queryToken, + }); +} + +export interface LegacyStartVectorYamlFields { + readonly apiKey: string; + readonly vectorId: string; + readonly logflareId: string; + readonly kongId: string; + readonly gotrueId: string; + readonly restId: string; + readonly realtimeId: string; + readonly storageId: string; + readonly edgeRuntimeId: string; + readonly dbId: string; +} + +/** Renders `vector.yaml` from Go's `vectorConfig` struct (`start.go:118-129`). */ +export function legacyRenderStartVectorYaml(fields: LegacyStartVectorYamlFields): string { + return legacyRenderGoTemplate(LEGACY_START_VECTOR_YAML_TEMPLATE, { + ApiKey: fields.apiKey, + VectorId: fields.vectorId, + LogflareId: fields.logflareId, + KongId: fields.kongId, + GotrueId: fields.gotrueId, + RestId: fields.restId, + RealtimeId: fields.realtimeId, + StorageId: fields.storageId, + EdgeRuntimeId: fields.edgeRuntimeId, + DbId: fields.dbId, + }); +} + +export interface LegacyStartPoolerExsFields { + readonly dbHost: string; + readonly dbPort: number; + readonly dbDatabase: string; + readonly dbPassword: string; + readonly externalId: string; + readonly modeType: string; + readonly defaultMaxClients: number; + readonly defaultPoolSize: number; +} + +/** Renders `pooler.exs` from Go's `poolerTenant` struct (`start.go:144-153`). */ +export function legacyRenderStartPoolerExs(fields: LegacyStartPoolerExsFields): string { + return legacyRenderGoTemplate(LEGACY_START_POOLER_EXS_TEMPLATE, { + DbHost: fields.dbHost, + DbPort: fields.dbPort, + DbDatabase: fields.dbDatabase, + DbPassword: fields.dbPassword, + ExternalId: fields.externalId, + ModeType: fields.modeType, + DefaultMaxClients: fields.defaultMaxClients, + DefaultPoolSize: fields.defaultPoolSize, + }); +} diff --git a/apps/cli/src/legacy/commands/start/lib/template-render.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/template-render.unit.test.ts new file mode 100644 index 0000000000..ba8c16e78b --- /dev/null +++ b/apps/cli/src/legacy/commands/start/lib/template-render.unit.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; + +import { LEGACY_START_KONG_YML_TEMPLATE } from "../templates/kong.yml.ts"; +import { LEGACY_START_POOLER_EXS_TEMPLATE } from "../templates/pooler.exs.ts"; +import { LEGACY_START_VECTOR_YAML_TEMPLATE } from "../templates/vector.yaml.ts"; +import { + legacyRenderGoTemplate, + legacyRenderStartKongYml, + legacyRenderStartPoolerExs, + legacyRenderStartVectorYaml, + type LegacyStartKongYmlFields, + type LegacyStartPoolerExsFields, + type LegacyStartVectorYamlFields, +} from "./template-render.ts"; + +const kongFields: LegacyStartKongYmlFields = { + gotrueId: "supabase_auth_test", + restId: "supabase_rest_test", + realtimeId: "supabase_realtime_test", + storageId: "supabase_storage_test", + studioId: "supabase_studio_test", + pgmetaId: "supabase_pg_meta_test", + edgeRuntimeId: "supabase_edge_runtime_test", + logflareId: "supabase_analytics_test", + poolerId: "supabase_pooler_test", + apiHost: "127.0.0.1", + apiPort: 54321, + bearerToken: "test-bearer-token", + queryToken: "test-query-token", +}; + +const vectorFields: LegacyStartVectorYamlFields = { + apiKey: "test-api-key", + vectorId: "supabase_vector_test", + logflareId: "supabase_analytics_test", + kongId: "supabase_kong_test", + gotrueId: "supabase_auth_test", + restId: "supabase_rest_test", + realtimeId: "supabase_realtime_test", + storageId: "supabase_storage_test", + edgeRuntimeId: "supabase_edge_runtime_test", + dbId: "supabase_db_test", +}; + +const poolerFields: LegacyStartPoolerExsFields = { + dbHost: "supabase_db_test", + dbPort: 6543, + dbDatabase: "postgres", + dbPassword: "postgres", + externalId: "supabase_pooler_test", + modeType: "transaction", + defaultMaxClients: 100, + defaultPoolSize: 20, +}; + +describe("legacyRenderGoTemplate", () => { + it("substitutes bare {{ .Field }} placeholders", () => { + expect(legacyRenderGoTemplate("hello {{ .Name }}", { Name: "world" })).toBe("hello world"); + }); + + it("tolerates any amount of whitespace inside the braces", () => { + expect(legacyRenderGoTemplate("{{.Name}} {{ .Name }} {{ .Name }}", { Name: "x" })).toBe( + "x x x", + ); + }); + + it("renders numeric fields as base-10 strings with no added quotes or decimals", () => { + expect(legacyRenderGoTemplate("port={{ .Port }}", { Port: 6543 })).toBe("port=6543"); + }); + + it("throws when a referenced field is missing (missingkey=error parity)", () => { + expect(() => legacyRenderGoTemplate("{{ .Missing }}", {})).toThrow(/\.Missing/); + }); + + it("does not error on struct fields that exist but are never referenced", () => { + expect(legacyRenderGoTemplate("{{ .Used }}", { Used: "a", Unused: "b" })).toBe("a"); + }); +}); + +describe("legacyRenderStartKongYml", () => { + it("replaces every placeholder with no template syntax or missing-value markers left behind", () => { + const rendered = legacyRenderStartKongYml(kongFields); + expect(rendered).not.toContain("{{"); + expect(rendered).not.toContain("}}"); + expect(rendered).not.toContain(""); + expect(rendered).not.toContain("undefined"); + }); + + it("interpolates each service upstream URL from the matching container id", () => { + const rendered = legacyRenderStartKongYml(kongFields); + expect(rendered).toContain("url: http://supabase_auth_test:9999/verify"); + expect(rendered).toContain("url: http://supabase_rest_test:3000/"); + expect(rendered).toContain("url: http://supabase_realtime_test:4000/socket"); + expect(rendered).toContain("url: http://supabase_storage_test:5000/s3"); + expect(rendered).toContain("url: http://supabase_edge_runtime_test:8081/"); + expect(rendered).toContain("url: http://supabase_pg_meta_test:8080/"); + expect(rendered).toContain("url: http://supabase_analytics_test:4000/"); + expect(rendered).toContain("url: http://supabase_pooler_test:4000/v2"); + expect(rendered).toContain("url: http://supabase_studio_test:3000/api/mcp"); + }); + + it("interpolates the bearer and query tokens into every header/querystring reference", () => { + const rendered = legacyRenderStartKongYml(kongFields); + expect(rendered).toContain('"Authorization: test-bearer-token"'); + expect(rendered).toContain('"sb-api-key: test-bearer-token"'); + expect(rendered).toContain('"apikey:test-query-token"'); + expect(rendered.match(/test-bearer-token/g)).toHaveLength( + (LEGACY_START_KONG_YML_TEMPLATE.match(/\{\{ \.BearerToken \}\}/g) ?? []).length, + ); + }); + + it("throws referencing the missing Go struct field when a placeholder has no value", () => { + const { gotrueId: _gotrueId, ...withoutGotrue } = kongFields; + const rawFields: Record = { + RestId: withoutGotrue.restId, + RealtimeId: withoutGotrue.realtimeId, + StorageId: withoutGotrue.storageId, + StudioId: withoutGotrue.studioId, + PgmetaId: withoutGotrue.pgmetaId, + EdgeRuntimeId: withoutGotrue.edgeRuntimeId, + LogflareId: withoutGotrue.logflareId, + PoolerId: withoutGotrue.poolerId, + ApiHost: withoutGotrue.apiHost, + ApiPort: withoutGotrue.apiPort, + BearerToken: withoutGotrue.bearerToken, + QueryToken: withoutGotrue.queryToken, + }; + expect(() => legacyRenderGoTemplate(LEGACY_START_KONG_YML_TEMPLATE, rawFields)).toThrow( + /\.GotrueId/, + ); + }); +}); + +describe("legacyRenderStartVectorYaml", () => { + it("replaces every placeholder with no template syntax or missing-value markers left behind", () => { + const rendered = legacyRenderStartVectorYaml(vectorFields); + expect(rendered).not.toContain("{{"); + expect(rendered).not.toContain("}}"); + expect(rendered).not.toContain(""); + expect(rendered).not.toContain("undefined"); + }); + + it("interpolates the vector, router, and logflare sink fields", () => { + const rendered = legacyRenderStartVectorYaml(vectorFields); + expect(rendered).toContain('- "supabase_vector_test"'); + expect(rendered).toContain("kong: '.appname == \"supabase_kong_test\"'"); + expect(rendered).toContain("auth: '.appname == \"supabase_auth_test\"'"); + expect(rendered).toContain("rest: '.appname == \"supabase_rest_test\"'"); + expect(rendered).toContain("realtime: '.appname == \"supabase_realtime_test\"'"); + expect(rendered).toContain("storage: '.appname == \"supabase_storage_test\"'"); + expect(rendered).toContain("functions: '.appname == \"supabase_edge_runtime_test\"'"); + expect(rendered).toContain("db: '.appname == \"supabase_db_test\"'"); + expect(rendered).toContain('x-api-key: "test-api-key"'); + expect(rendered).toContain( + 'uri: "http://supabase_analytics_test:4000/api/logs?source_name=gotrue.logs.prod"', + ); + }); + + it("throws referencing the missing Go struct field when a placeholder has no value", () => { + const { apiKey: _apiKey, ...withoutApiKey } = vectorFields; + const rawFields: Record = { + VectorId: withoutApiKey.vectorId, + LogflareId: withoutApiKey.logflareId, + KongId: withoutApiKey.kongId, + GotrueId: withoutApiKey.gotrueId, + RestId: withoutApiKey.restId, + RealtimeId: withoutApiKey.realtimeId, + StorageId: withoutApiKey.storageId, + EdgeRuntimeId: withoutApiKey.edgeRuntimeId, + DbId: withoutApiKey.dbId, + }; + expect(() => legacyRenderGoTemplate(LEGACY_START_VECTOR_YAML_TEMPLATE, rawFields)).toThrow( + /\.ApiKey/, + ); + }); +}); + +describe("legacyRenderStartPoolerExs", () => { + it("renders the exact expected Elixir source", () => { + expect(legacyRenderStartPoolerExs(poolerFields)).toBe( + `{:ok, _} = Application.ensure_all_started(:supavisor) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => "supabase_pooler_test", + "db_host" => "supabase_db_test", + "db_port" => 6543, + "db_database" => "postgres", + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => 100, + "default_pool_size" => 20, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => "postgres", + "mode_type" => "transaction", + "pool_size" => 20, + "is_manager" => true + }] +} + +if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + {:ok, _} = Supavisor.Tenants.create_tenant(params) +end +`, + ); + }); + + it("renders numeric fields as bare Elixir integer literals with no quotes or decimals", () => { + const rendered = legacyRenderStartPoolerExs(poolerFields); + expect(rendered).toContain('"db_port" => 6543,'); + expect(rendered).toContain('"default_max_clients" => 100,'); + expect(rendered).toContain('"default_pool_size" => 20,'); + expect(rendered).toContain('"pool_size" => 20,'); + expect(rendered).not.toMatch(/"db_port" => "6543"/); + expect(rendered).not.toMatch(/"db_port" => 6543\.0/); + }); + + it("throws referencing the missing Go struct field when a placeholder has no value", () => { + const { dbHost: _dbHost, ...withoutDbHost } = poolerFields; + const rawFields: Record = { + DbPort: withoutDbHost.dbPort, + DbDatabase: withoutDbHost.dbDatabase, + DbPassword: withoutDbHost.dbPassword, + ExternalId: withoutDbHost.externalId, + ModeType: withoutDbHost.modeType, + DefaultMaxClients: withoutDbHost.defaultMaxClients, + DefaultPoolSize: withoutDbHost.defaultPoolSize, + }; + expect(() => legacyRenderGoTemplate(LEGACY_START_POOLER_EXS_TEMPLATE, rawFields)).toThrow( + /\.DbHost/, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts new file mode 100644 index 0000000000..91ec439953 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -0,0 +1,236 @@ +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { beforeEach } from "vitest"; + +import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + legacyStartEdgeRuntimeContainer, + type LegacyEdgeRuntimeBringUpInput, +} from "./edge-runtime.service.ts"; + +/** + * A spawner that answers every `docker` invocation + * `legacyStartEdgeRuntimeContainer`'s call chain makes + * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the `docker run -d` bring-up + * itself/`reloadKong`'s `docker exec`) with success, recording every + * invocation's argv for assertions — same shape as + * `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret values are + * delivered to the `run -d` call via `--env-file`/a bind-mounted script, not + * this spawned process's own environment (see `edge-runtime.service.ts`'s + * header for why), so there is nothing to capture beyond argv. + */ +function mockDockerSpawner() { + const calls: Array<{ args: ReadonlyArray }> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + calls.push({ args }); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get calls() { + return calls; + }, + get runCall() { + return calls.find((call) => call.args[0] === "run" && call.args[1] === "-d"); + }, + }; +} + +function baseInput(workdir: string): LegacyEdgeRuntimeBringUpInput { + return { + projectId: "proj", + networkId: "supabase_network_proj", + image: "registry.example.com/supabase/edge-runtime:v1.74.2", + workdir, + // `authenticator:postgres@127.0.0.1:54322/postgres` — password "postgres", matching every + // other service's `dbUrl` input shape (`LegacyLocalConfigValues.dbUrl`). + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + apiPort: 54321, + edgeRuntimePolicy: "oneshot", + edgeRuntimeInspectorPort: 8083, + edgeRuntimeSecrets: {}, + configDeclaredFunctions: {}, + configFunctions: {}, + rawConfigFunctions: {}, + authArtifacts: { + publishableKey: "sb_publishable_local", + secretKey: "sb_secret_local", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + anonKey: "anon.jwt.value", + serviceRoleKey: "service-role.jwt.value", + jwks: '{"keys":[]}', + }, + debug: false, + platform: "darwin", + }; +} + +function envEntries(runCall: { + args: ReadonlyArray; + env?: Readonly>; +}) { + const envFileArgIndex = runCall.args.indexOf("--env-file"); + const envFilePath = runCall.args[envFileArgIndex + 1]; + expect(envFilePath).toBeDefined(); + return readFileSync(envFilePath!, "utf8") + .split("\n") + .filter((line) => line.length > 0); +} + +describe("legacyStartEdgeRuntimeContainer", () => { + const tempWorkdir = useLegacyTempWorkdir("supabase-edge-runtime-service-int-"); + + // An empty functions directory — every scenario here has zero declared + // functions (`configDeclaredFunctions`/`configFunctions` in `baseInput`), + // so nothing under it is ever read; it only needs to exist. + beforeEach(() => { + mkdirSync(join(tempWorkdir.current, "supabase", "functions"), { recursive: true }); + }); + + it.effect( + "sends the real internal db url (db container name, port 5432, config.db.password) — NOT functions serve's `db`-alias default", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const entries = envEntries(mock.runCall!); + expect(entries).toContain( + "SUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_proj:5432/postgres", + ); + }), + ); + + it.effect("passes every already-resolved auth artifact through unchanged", () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const entries = envEntries(mock.runCall!); + expect(entries).toContain("SUPABASE_ANON_KEY=anon.jwt.value"); + expect(entries).toContain("SUPABASE_SERVICE_ROLE_KEY=service-role.jwt.value"); + expect(entries).toContain( + "SUPABASE_INTERNAL_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long", + ); + expect(entries).toContain("SUPABASE_INTERNAL_PUBLISHABLE_KEY=sb_publishable_local"); + expect(entries).toContain("SUPABASE_INTERNAL_SECRET_KEY=sb_secret_local"); + expect(entries).toContain('SUPABASE_JWKS={"keys":[]}'); + expect(entries).toContain("SUPABASE_INTERNAL_HOST_PORT=54321"); + }), + ); + + it.effect( + "never applies functions serve's own CLI-only overrides (no inspector port, verifyJWT defaults on)", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + expect(runCall.args).not.toContain("-p"); + expect(runCall.args.join(" ")).not.toContain("--inspect"); + }), + ); + + it.effect( + "sets --workdir and --ulimit nofile=65536:65536, matching Go's WorkingDir/Ulimits container.Config", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + const workdirIndex = runCall.args.indexOf("--workdir"); + expect(workdirIndex).toBeGreaterThanOrEqual(0); + expect(runCall.args[workdirIndex + 1]).toBe(tempWorkdir.current); + const ulimitIndex = runCall.args.indexOf("--ulimit"); + expect(runCall.args[ulimitIndex + 1]).toBe("nofile=65536:65536"); + }), + ); + + it.effect( + "joins the caller-supplied network id and uses the already-resolved image, unmodified", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + const input = { + ...baseInput(tempWorkdir.current), + networkId: "custom_network_override", + image: "registry.example.com/supabase/edge-runtime:v1.99.9", + }; + + yield* legacyStartEdgeRuntimeContainer(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const runCall = mock.runCall!; + const networkIndex = runCall.args.indexOf("--network"); + expect(runCall.args[networkIndex + 1]).toBe("custom_network_override"); + expect(runCall.args).toContain("registry.example.com/supabase/edge-runtime:v1.99.9"); + }), + ); + + it.effect( + "resolves with the started container's id and a cleanup effect left for the caller to run", + () => + Effect.gen(function* () { + const mock = mockDockerSpawner(); + const out = mockOutput(); + + const started = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + expect(started.containerId).toBe("supabase_edge_runtime_proj"); + yield* started.cleanup; + }), + ); +}); diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts new file mode 100644 index 0000000000..c1c3958b6f --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -0,0 +1,204 @@ +/** + * Real bring-up for the Edge Runtime container — Go's "Start all functions" + * block (`apps/cli-go/internal/start/start.go:1101-1108`): + * + * ```go + * if utils.Config.EdgeRuntime.Enabled && !isContainerExcluded(utils.Config.EdgeRuntime.Image, excluded) { + * dbUrl := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) + * if err := serve.ServeFunctions(ctx, "", nil, "", dbUrl, serve.RuntimeOption{}, fsys); err != nil { + * return err + * } + * started = append(started, utils.EdgeRuntimeId) + * } + * ``` + * + * Unlike its 12 siblings in this directory, this module does NOT build a + * `LegacyStartContainerSpec` for `legacyStartContainer` + * (`../lib/container-lifecycle.ts`) to create+start uniformly. That + * unification (`docker create`/`docker start`, `-e KEY`-only env with values + * supplied via the spawned process's own environment) was evaluated against + * what `shared/functions/serve.ts`'s `startEdgeRuntimeContainer` actually + * spawns and rejected as NOT a clean mapping: + * + * - `docker-create-args.ts`'s own header explicitly excludes `WorkingDir` + * and `Ulimits` from `LegacyStartContainerSpec` ("none of the 13 [other] + * call sites... set them") — Edge Runtime needs BOTH (`--workdir`, + * `--ulimit nofile=65536:65536`), so "mapping cleanly" would mean + * extending the shared spec for a single caller. + * - Every other service's env travels as bare `-e KEY` flags whose values + * come from the spawned `docker create` process's own environment + * (`container-lifecycle.ts`'s `legacyDockerCreateContainer`). Edge + * Runtime instead needs an `--env-file` for ordinary secrets AND a + * separate bind-mounted, `sh`-sourced multiline-env script for any secret + * containing a newline (`docker env-file` is a line-oriented format that + * cannot represent one) — a second env-delivery mechanism the shared spec + * has no concept of. + * - `docker run -d` (one atomic call) vs. `docker create` + `docker start` + * (two calls) is a structural argv difference, not a flag-mapping detail. + * + * So this module keeps `startEdgeRuntimeContainer`'s direct `docker run -d` + * exactly as `functions serve` already spawns it (see that module's own doc + * comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct + * bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop + * — NOT a spec for `legacyStartContainer` to create. `start.handler.ts`'s + * wiring must special-case Edge Runtime's bring-up call, the same way it + * already special-cases Postgres's (also called directly, not through the + * generic `buildSpecForService` switch, since it needs its own health-wait + * pass before the other services start). + * + * `SUPABASE_DB_URL` is the one thing that genuinely differs from what + * `functions serve` sends: standalone `functions serve` hardcodes the `db` + * network alias (`shared/functions/serve.ts`'s `legacyDefaultServeDbUrl`, + * matching Go's `restartEdgeRuntime`), but `start`'s own direct call + * (`start.go:66-72,1103`) uses the real `dbConfig` — the `db` container's own + * sanitized name and `config.db.password` — exactly like the other 12 + * services' `dbHost`/`dbPassword` derivation + * (`../lib/internal-db-connection.ts`). {@link legacyStartEdgeRuntimeContainer} + * reproduces that real value, not `functions serve`'s alias-based default. + */ + +import { Effect, Option } from "effect"; + +import { + startEdgeRuntimeContainer, + type ServeAuthArtifacts, + type ServeEdgeRuntimeContainerConfig, +} from "../../../../shared/functions/serve.ts"; +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "../lib/internal-db-connection.ts"; + +export interface LegacyEdgeRuntimeBringUpInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.EdgeRuntime.Image`, already resolved/pulled by the caller (`image-prepull.ts`/`legacyResolveEdgeRuntimeImage`). */ + readonly image: string; + /** + * Go's `cwd`/`utils.CurrentDirAbs` for this call — `cliConfig.workdir` in + * `start.handler.ts`. Used as `functions serve`'s `projectRoot`/`flagCwd` + * (no separate "flag cwd" exists for `start`) and to derive `supabaseDir` + * (`/supabase`). + */ + readonly workdir: string; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB host/password (matches every other service builder's own `dbUrl` input). */ + readonly dbUrl: string; + /** `config.api.port` — `SUPABASE_INTERNAL_HOST_PORT`. */ + readonly apiPort: number; + /** `config.edge_runtime.policy`. */ + readonly edgeRuntimePolicy: string; + /** `config.edge_runtime.inspector_port` — only published when `inspectMode` is set, which `start` never does (Go's `serve.RuntimeOption{}` zero value). */ + readonly edgeRuntimeInspectorPort: number; + /** + * `config.edge_runtime.secrets`, already unwrapped/uppercased — build via + * `shared/functions/serve.ts`'s exported `toPlainEdgeRuntimeConfig(config.edge_runtime).secrets` + * rather than re-deriving the `Redacted`-unwrap/uppercase logic here. + */ + readonly edgeRuntimeSecrets: Readonly>; + /** + * `config.functions`, already unwrapped — build via + * `shared/functions/serve.ts`'s exported `toPlainFunctionRecord(config.functions)`. + */ + readonly configDeclaredFunctions: ServeEdgeRuntimeContainerConfig["configDeclaredFunctions"]; + /** + * The functions directory manifest — build via `@supabase/config`'s + * `inferFunctionsManifest({ cwd: workdir, config })`, same as + * `shared/functions/serve.ts`'s own `resolveServeConfig` does. + */ + readonly configFunctions: ServeEdgeRuntimeContainerConfig["configFunctions"]; + /** + * The raw, pre-schema `[functions.*]` TOML table — build via + * `shared/functions/deploy.ts`'s exported `rawFunctionConfigRecord(document)` + * against the same raw document `start.handler.ts` already loads (its own + * `context.loaded?.document`). + */ + readonly rawConfigFunctions: ServeEdgeRuntimeContainerConfig["rawConfigFunctions"]; + /** + * Every already-resolved secret/key this container needs — `start`'s own + * `values.{publishableKey,secretKey,jwtSecret,anonKey,serviceRoleKey}` plus + * `jwks` (`legacyResolveLocalJwks`'s result), NOT independently re-resolved + * (see this module's header). + */ + readonly authArtifacts: ServeAuthArtifacts; + /** The global `--debug` flag. */ + readonly debug: boolean; + /** `RuntimeInfo.platform`/`process.platform` — gates the Linux-only `--add-host host.docker.internal:host-gateway` flag. */ + readonly platform: NodeJS.Platform; +} + +/** + * Bring up the Edge Runtime container for `start`, delegating the entire + * `docker run` argv/env assembly to `shared/functions/serve.ts`'s + * `startEdgeRuntimeContainer` (already ported for `functions serve`) with + * `start`'s own already-resolved config/secrets in place of that command's + * independent config-loading pipeline. `start.handler.ts`'s bring-up loop + * should call this directly (NOT `legacyStartContainer`) for the Edge Runtime + * entry in its service list, gated the same way as every other service on + * `config.edge_runtime.enabled && !isContainerExcluded(...)`. + * + * Resolves to the same `StartedRuntime` shape `functions serve` itself + * gets back. `containerId` is what the caller adds to its post-bring-up + * health-wait list (pairing it with an `edgeRuntime` gateway on + * `LegacyWaitForHealthyServicesOptions`, `../lib/health-check.ts` — the same + * shape as the existing `postgrest` gateway). `watchSpecs` is + * `functions serve`-only file-watch plumbing and can be ignored here. + * + * `cleanup` (removing the temp env-file/multiline-env-script/serve-main- + * template files this call writes to the host) is intentionally left to the + * caller, and the caller must NOT invoke it on a successful bring-up. Unlike + * every other `start` service (`legacyStartContainer`'s `restartPolicy: + * "unless-stopped"`), Go's own Edge Runtime bring-up (`serve.ServeFunctions`, + * `internal/functions/serve/serve.go:218-241`) sets NO Docker restart policy + * at all — its lifecycle is deliberately reconciled at the CLI level + * (`restartEdgeRuntime`, `serve.go:108-133`), not the Docker daemon level — + * so this container's own `docker run` (`shared/functions/serve.ts`) + * intentionally omits `--restart` too. Its bind-mounted host paths must + * still exist for as long as the container itself can be reattached to + * (e.g. a plain `docker start` by the user, or discovery by a later CLI + * invocation) — the same reasoning `legacyStageStartSecretFiles` + * (`../lib/container-lifecycle.ts`) already applies to every other service's + * staged secret files. `startEdgeRuntimeContainer` (`shared/functions/ + * serve.ts`) already runs `cleanup` internally on a failed or interrupted + * bring-up (`Effect.onInterrupt`, and the non-zero-exit branch), so the + * caller only needs to leave the returned `cleanup` unused on success. + */ +export const legacyStartEdgeRuntimeContainer = Effect.fn("legacy.start.edgeRuntime")(function* ( + input: LegacyEdgeRuntimeBringUpInput, +) { + return yield* startEdgeRuntimeContainer({ + config: { + projectId: input.projectId, + apiPort: input.apiPort, + edgeRuntimePolicy: input.edgeRuntimePolicy, + edgeRuntimeInspectorPort: input.edgeRuntimeInspectorPort, + edgeRuntimeSecrets: input.edgeRuntimeSecrets, + configDeclaredFunctions: input.configDeclaredFunctions, + configFunctions: input.configFunctions, + rawConfigFunctions: input.rawConfigFunctions, + }, + authArtifacts: input.authArtifacts, + dbUrl: legacyStartInternalDbUrl( + "postgres", + legacyServiceContainerName("db", input.projectId), + legacyStartInternalDbPassword(input.dbUrl), + ), + image: input.image, + projectRoot: input.workdir, + supabaseDir: `${input.workdir}/supabase`, + flagCwd: input.workdir, + platform: input.platform, + debug: input.debug, + networkId: input.networkId, + // Go's `start.go:1104` passes `serve.RuntimeOption{}` (its zero value) + // and an empty `envFilePath`/`nil` `noVerifyJWT`/empty `importMapPath` + // — `start` has no CLI flags of its own for any of these. + envFile: Option.none(), + importMap: Option.none(), + noVerifyJwt: Option.none(), + inspectMode: undefined, + inspectMain: false, + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts new file mode 100644 index 0000000000..39075e8a94 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -0,0 +1,699 @@ +/** + * GoTrue/Auth env + container spec builder — port of Go's "Start GoTrue" + * block (`apps/cli-go/internal/start/start.go:629-851`) and its + * `buildGotrueEnv`/`appendGotruePasskeyEnv`/`appendGotrueExternalProviderEnv`/ + * `formatMapForEnvConfig` helpers (`start.go:1305-1462`). Gated in Go by + * `config.auth.enabled` and `!isContainerExcluded(config.auth.image, + * excluded)` — see `legacy-service-catalog.ts`'s `gotrue` entry (`excludeKey: + * "gotrue"`, gated on `auth.enabled`). Gating and image resolution/pre-pull + * are the caller's job (a future `start.handler.ts`); this module only + * assembles the env/container spec once the caller has already decided to + * start it. + * + * {@link buildLegacyGotrueEnv} deliberately reproduces the FULL env surface + * Go's container actually receives — not just Go's narrowly-named + * `buildGotrueEnv()` return value, but every conditional env var the + * surrounding "Start GoTrue" block in `run()` appends on top of it (JWT + * signing keys, SMTP/Mailpit fallback, mailer template/notification URLs, the + * fixed-priority SMS provider switch, CAPTCHA, the six auth hooks, MFA phone + * extras, passkey/WebAuthn, external OAuth providers, Web3, OAuth server). + * Ported test: `apps/cli-go/internal/start/start_test.go:440 TestBuildGotrueEnv` + * (+`:566 TestFormatMapForEnvConfig`), see `gotrue.service.unit.test.ts`. + * + * `@supabase/config` schema gaps this module works around (all pre-existing, + * not introduced here — see each input field's own doc comment): + * - `auth.external_url` has no schema field at all, so the caller reads it + * off the raw TOML document (same pattern as the gaps below) and passes + * the resolved value in as {@link LegacyBuildGotrueEnvInput.authExternalUrl} + * — when set, it wins over the `apiUrl`-derived fallback for + * `API_EXTERNAL_URL`/`GOTRUE_JWT_ISSUER`'s default/the mailer verify URL/ + * OAuth redirect-URI fallbacks, matching `auth.GetExternalURL` + * (`pkg/config/auth.go:401-405`). `config/push/config-sync/auth.sync.ts` + * has the identical gap, unresolved — a separate command, not this PR's + * scope. + * - `auth.captcha`/`auth.passkey`/`auth.webauthn`/`auth.email.smtp`'s + * presence-and-default quirks (Go's `Config.Validate`/`Config.Load` + * treat an explicitly-omitted `enabled` differently depending on whether + * the surrounding TOML table is present at all) can't be recovered from + * the decoded `ProjectConfig` alone — the caller resolves these the same + * way `legacy-local-config-values.ts` already does (reading the raw + * TOML document) and passes the final, presence-resolved values in. + * - `auth.external` decodes as a FIXED struct of ~19 known providers, each + * always present with `enabled: false` when unconfigured — but Go's own + * `Auth.External` is a genuine `map[string]provider{}` that only ever + * contains the providers a user's `config.toml` actually mentions, and + * `appendGotrueExternalProviderEnv` iterates that real map unconditionally + * (emitting `_ENABLED=false` etc. for a configured-but-disabled provider, + * never for an unconfigured one). {@link LegacyBuildGotrueEnvInput.externalProviders} + * must therefore already be presence-filtered by the caller (only the + * providers whose `[auth.external.]` section actually exists in + * the TOML document), the same way `legacy-local-config-values.ts`'s + * `validateAuthExternalProviders` already filters for its own purposes. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + legacyFormatGoDuration, + legacyParseGoDuration, +} from "../../../shared/legacy-go-duration.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; +import type { LegacyResolvedAuthEmail } from "../../../shared/legacy-local-config-values.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "../lib/internal-db-connection.ts"; + +/** `utils.GotrueAliases[0]` (`apps/cli-go/internal/utils/config.go:38`) — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ +const LEGACY_GOTRUE_CONTAINER_SUFFIX = "auth"; + +/** GoTrue's fixed listen port (`start.go:825`, `ExposedPorts: nat.PortSet{"9999/tcp": {}}`) — never published to the host. */ +const LEGACY_GOTRUE_PORT = "9999"; + +/** `nginxTemplateServerPort` (`start.go:115`) — Kong's nginx template server, used for mailer template/subject URLs. */ +const LEGACY_GOTRUE_NGINX_TEMPLATE_SERVER_PORT = 8088; + +/** Go's `Config.Inbucket.AdminEmail`/`SenderName` defaults (`pkg/config/config.go:518-520`, `NewConfig()`). */ +const LEGACY_GOTRUE_DEFAULT_INBUCKET_ADMIN_EMAIL = "admin@email.com"; +const LEGACY_GOTRUE_DEFAULT_INBUCKET_SENDER_NAME = "Admin"; + +/** `supabase_auth_admin` — the fixed Postgres role GoTrue's `GOTRUE_DB_DATABASE_URL` authenticates as (`start.go:1363`). */ +const LEGACY_GOTRUE_DB_ROLE = "supabase_auth_admin"; + +/** + * RFC 7517 JWK fields Go's `JWK` struct round-trips + * (`apps/cli-go/pkg/config/auth.go:88-108`), in the exact `json:"..."` field + * declaration order — needed so {@link buildLegacyGotrueEnv}'s + * `JSON.stringify` reproduces Go's `encoding/json.Marshal` byte-for-byte + * (both languages serialize object/struct keys in declaration order). + * Structurally near-identical to `legacy/shared/legacy-go-jwt.ts`'s `LegacyJwk` + * (the only difference is `key_ops`'s mutable `string[]` there, needed for + * assignability into Node's `createPrivateKey`/`JsonWebKey` input — see that + * type's own doc comment) and a superset of `shared/auth/jwks.ts`'s `JwkLike` + * (which omits the private-key fields `d`/`p`/`q`/`dp`/`dq`/`qi`) — kept local + * rather than reusing either shared type, since neither of their existing + * callers needs the union of all seventeen fields. + */ +export interface LegacyGotrueSigningKey { + readonly kty: string; + readonly kid?: string; + readonly use?: string; + readonly key_ops?: ReadonlyArray; + readonly alg?: string; + readonly ext?: boolean; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} + +/** + * `Config.Auth.SigningKeys`' default single entry (`pkg/config/config.go:504-515`, + * `NewConfig()`) — used whenever `auth.signing_keys_path` is unset. Hoisted to + * `legacy-go-jwt.ts` (as `LEGACY_DEFAULT_SIGNING_KEY`) so `legacyResolveLocalJwks` + * publishes the exact same key's public form in the JWKS this default signs + * with — the two must never disagree on which key is "the" default. + */ +const LEGACY_GOTRUE_DEFAULT_SIGNING_KEY: LegacyGotrueSigningKey = LEGACY_DEFAULT_SIGNING_KEY; + +/** + * Go `PasswordRequirements.ToChar()` (`apps/cli-go/pkg/config/auth.go:34-44`). + * Values are the real Management API `password_required_characters` literals + * (the `:`-separated character classes are significant), matching the + * identical, currently un-hoisted mapping in + * `config/push/config-sync/auth.sync.ts`'s `PASSWORD_REQUIREMENTS_TO_CHAR`. + */ +const LEGACY_GOTRUE_PASSWORD_REQUIREMENTS_TO_CHAR: Record = { + letters_digits: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + lower_upper_letters_digits: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + lower_upper_letters_digits_symbols: + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", +}; + +function legacyGotruePasswordRequirementsToChar( + value: ProjectConfig["auth"]["password_requirements"], +): string { + return LEGACY_GOTRUE_PASSWORD_REQUIREMENTS_TO_CHAR[value] ?? ""; +} + +/** + * Go's `formatMapForEnvConfig(input map[string]string, output *bytes.Buffer)` + * (`start.go:1305-1317`) — the `auth.sms.test_otp` map's `GOTRUE_SMS_TEST_OTP` + * wire format: `key:value` pairs, comma-joined, no trailing comma. Go iterates + * a `map[string]string` (unordered); this iterates `Object.entries` (insertion + * order) instead — order is not part of Go's own contract here (map iteration + * order is intentionally randomized in Go), so this doesn't need to match any + * particular Go run's order, only the `key:value,...` shape. + */ +export function legacyFormatMapForEnvConfig(input: Readonly>): string { + return Object.entries(input) + .map(([key, value]) => `${key}:${value}`) + .join(","); +} + +/** One already-resolved, presence-gated `[auth.hook.]` entry — see this module's header for why presence must be pre-resolved by the caller. */ +export interface LegacyGotrueHookInput { + readonly enabled: boolean; + readonly uri?: string; + readonly secrets?: string; +} + +/** `[auth.webauthn]`, present iff the caller's raw TOML document has that section (`Auth.Webauthn != nil` in Go). */ +export interface LegacyGotrueWebauthnInput { + readonly rpId: string; + readonly rpDisplayName: string; + readonly rpOrigins: ReadonlyArray; +} + +/** + * One already-presence-filtered `[auth.external.]` entry — see this + * module's header for why the caller must filter the whole map by TOML + * presence before calling {@link buildLegacyGotrueEnv}. + */ +export interface LegacyGotrueExternalProviderInput { + readonly enabled: boolean; + readonly clientId: string; + readonly secret?: string; + readonly url: string; + readonly redirectUri?: string; + readonly skipNonceCheck: boolean; + readonly emailOptional: boolean; +} + +export interface LegacyBuildGotrueEnvInput { + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`) — Go's `dbConfig.Host`. */ + readonly dbHost: string; + /** Go's `dbConfig.Password` (`Config.Db.Password`) — see {@link legacyStartInternalDbPassword}. */ + readonly dbPassword: string; + + /** + * `LegacyLocalConfigValues.apiUrl` (Go's resolved `Config.Api.ExternalUrl`) + * — reused, not recomputed, so `API_EXTERNAL_URL`/`GOTRUE_JWT_ISSUER`'s + * derived fallback (used when {@link authExternalUrl} is unset) never + * drifts from what `status` reports. + */ + readonly apiUrl: string; + /** + * Raw `auth.external_url` (already `SUPABASE_AUTH_EXTERNAL_URL`-overridden + * by the caller) — Go's `Config.Auth.ExternalUrl`/`AuthExternalURL()` + * (`pkg/config/config.go:543-545`, `auth.go:401-405`): an explicit value + * wins outright, otherwise Go derives `TrimRight(apiUrl, "/") + "/auth/v1"`. + * `undefined`/empty means unset — fall back to the `apiUrl` derivation. + */ + readonly authExternalUrl?: string; + /** `LegacyLocalConfigValues.jwtSecret` — reused, not recomputed. */ + readonly jwtSecret: string; + /** `config.auth.jwt_issuer`, raw (`undefined` when unset — falls back to the derived auth-external URL, matching `Config.Load`'s `if len(c.Auth.JwtIssuer) == 0`). */ + readonly jwtIssuer: string | undefined; + readonly jwtExpiry: ProjectConfig["auth"]["jwt_expiry"]; + readonly siteUrl: ProjectConfig["auth"]["site_url"]; + readonly additionalRedirectUrls: ProjectConfig["auth"]["additional_redirect_urls"]; + readonly enableSignup: ProjectConfig["auth"]["enable_signup"]; + readonly enableAnonymousSignIns: ProjectConfig["auth"]["enable_anonymous_sign_ins"]; + readonly enableRefreshTokenRotation: ProjectConfig["auth"]["enable_refresh_token_rotation"]; + readonly refreshTokenReuseInterval: ProjectConfig["auth"]["refresh_token_reuse_interval"]; + readonly enableManualLinking: ProjectConfig["auth"]["enable_manual_linking"]; + readonly minimumPasswordLength: ProjectConfig["auth"]["minimum_password_length"]; + readonly passwordRequirements: ProjectConfig["auth"]["password_requirements"]; + + /** + * `Config.Auth.SigningKeys`, already resolved (the `auth.signing_keys_path` + * file's contents via `legacy-local-config-values.ts`'s `loadSigningKeys`, + * when configured). Defaults to Go's hardcoded single ES256 key + * ({@link LEGACY_GOTRUE_DEFAULT_SIGNING_KEY}) when omitted, matching + * `NewConfig()`'s default when `signing_keys_path` is unset. + */ + readonly signingKeys?: ReadonlyArray; + + /** `config.auth.email`, everything except `smtp` (kept separate — see {@link smtp}). */ + readonly email: Omit; + /** Kong's own container name (Go's `utils.KongId`) — mailer template/subject URLs route through it. */ + readonly kongContainerName: string; + + /** + * `config.auth.email.smtp`, already presence-and-default resolved by the + * caller (Go's `Auth.Email.Smtp != nil && Auth.Email.Smtp.Enabled` — + * `@supabase/config` can't reproduce Go's "TOML table present, `enabled` + * key absent → true" default on its own, see this module's header). + * `undefined` means Go's `Smtp == nil || !Smtp.Enabled` — the mailpit + * fallback below applies instead. + */ + readonly smtp?: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; + readonly senderName?: string; + }; + /** + * `config.local_smtp` (Go's `Inbucket`), present iff `local_smtp.enabled` + * AND {@link smtp} is unset (Go's `else if utils.Config.Inbucket.Enabled`). + * `adminEmail`/`senderName` default to Go's `NewConfig()` literals when + * unset, matching `@supabase/config`'s `inbucket.ts` schema having no + * default for either (unlike Go, which always has a literal default even + * when the key is absent from `config.toml`). + */ + readonly mailpit?: { + readonly containerName: string; + readonly adminEmail?: string; + readonly senderName?: string; + }; + + readonly sms: ProjectConfig["auth"]["sms"]; + readonly sessions: ProjectConfig["auth"]["sessions"]; + readonly mfa: ProjectConfig["auth"]["mfa"]; + readonly rateLimit: ProjectConfig["auth"]["rate_limit"]; + readonly web3: ProjectConfig["auth"]["web3"]; + readonly oauthServer: ProjectConfig["auth"]["oauth_server"]; + /** + * `config.auth.hook.`, each already presence-resolved AND + * env-override-resolved by the caller (`legacyResolveAuthHooks` — + * `SUPABASE_AUTH_HOOK__ENABLED`/`_URI`/`_SECRETS`). + */ + readonly hooks: { + readonly mfaVerificationAttempt: LegacyGotrueHookInput; + readonly passwordVerificationAttempt: LegacyGotrueHookInput; + readonly customAccessToken: LegacyGotrueHookInput; + readonly sendSms: LegacyGotrueHookInput; + readonly sendEmail: LegacyGotrueHookInput; + readonly beforeUserCreated: LegacyGotrueHookInput; + }; + + /** + * `config.auth.captcha`, already presence-resolved AND env-override-resolved + * by the caller (`legacyResolveAuthCaptcha` — `SUPABASE_AUTH_CAPTCHA_ENABLED`/ + * `_PROVIDER`/`_SECRET`, `secret` decrypted like every other `Secret`-typed + * field). `@supabase/config`'s `withDecodingDefaultKey` fills in `{enabled: + * false}` even when `[auth.captcha]` is absent — see this module's header. + * `undefined` means `Auth.Captcha == nil` in Go. + */ + readonly captcha?: { + readonly enabled: boolean; + readonly provider?: string; + readonly secret?: string; + }; + + /** `[auth.passkey].enabled`, `undefined` iff the section is absent — see this module's header (not in `@supabase/config`'s schema at all). */ + readonly passkeyEnabled?: boolean; + /** `[auth.webauthn]`, `undefined` iff the section is absent — independent of {@link passkeyEnabled} (Go checks both pointers separately). */ + readonly webauthn?: LegacyGotrueWebauthnInput; + + /** Already presence-filtered by the caller — see this module's header. */ + readonly externalProviders: Readonly>; +} + +function legacyTrimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +/** + * Port of Go's `appendGotruePasskeyEnv` (`start.go:1427-1440`). Two + * INDEPENDENT presence gates (`Auth.Passkey != nil`, `Auth.Webauthn != nil`) + * — Go's `Config.Validate` requires `Auth.Webauthn` whenever + * `Auth.Passkey.Enabled`, but this function doesn't assume that invariant, + * matching Go's own un-assuming implementation. + */ +function legacyAppendGotruePasskeyEnv( + env: Record, + passkeyEnabled: boolean | undefined, + webauthn: LegacyGotrueWebauthnInput | undefined, +): void { + if (passkeyEnabled !== undefined) { + env["GOTRUE_PASSKEY_ENABLED"] = String(passkeyEnabled); + } + if (webauthn !== undefined) { + env["GOTRUE_WEBAUTHN_RP_ID"] = webauthn.rpId; + env["GOTRUE_WEBAUTHN_RP_DISPLAY_NAME"] = webauthn.rpDisplayName; + env["GOTRUE_WEBAUTHN_RP_ORIGINS"] = webauthn.rpOrigins.join(","); + } +} + +/** + * Port of Go's `appendGotrueExternalProviderEnv` (`start.go:1442-1462`). + * Iterates `providers` unconditionally (every entry, enabled or not) — Go's + * loop has no `if config.Enabled` gate at all, only the trailing `URL` field + * is conditional. See this module's header for why `providers` must already + * be presence-filtered by the caller. + */ +function legacyAppendGotrueExternalProviderEnv( + env: Record, + providers: Readonly>, + jwtIssuer: string, +): void { + for (const [name, provider] of Object.entries(providers)) { + const upper = name.toUpperCase(); + const redirectUri = + provider.redirectUri !== undefined && provider.redirectUri.length > 0 + ? provider.redirectUri + : `${jwtIssuer}/callback`; + env[`GOTRUE_EXTERNAL_${upper}_ENABLED`] = String(provider.enabled); + env[`GOTRUE_EXTERNAL_${upper}_CLIENT_ID`] = provider.clientId; + env[`GOTRUE_EXTERNAL_${upper}_SECRET`] = provider.secret ?? ""; + env[`GOTRUE_EXTERNAL_${upper}_SKIP_NONCE_CHECK`] = String(provider.skipNonceCheck); + env[`GOTRUE_EXTERNAL_${upper}_EMAIL_OPTIONAL`] = String(provider.emailOptional); + env[`GOTRUE_EXTERNAL_${upper}_REDIRECT_URI`] = redirectUri; + if (provider.url.length > 0) { + env[`GOTRUE_EXTERNAL_${upper}_URL`] = provider.url; + } + } +} + +/** + * `json.Marshal`-equivalent for one signing key: builds a plain object with + * keys in Go's `JWK` struct declaration order, omitting every field Go's + * `omitempty` would drop (an absent/`undefined` field, or an empty string — + * `Extractable`/`KeyOps` are pointer/slice-typed in Go, so only `undefined`, + * not a falsy value, is dropped for those two). `JSON.stringify` then + * serializes in that same insertion order, matching `encoding/json.Marshal` + * byte-for-byte. + */ +function legacyOrderGotrueSigningKey(key: LegacyGotrueSigningKey): Record { + const ordered: Record = { kty: key.kty }; + if (key.kid !== undefined && key.kid.length > 0) ordered["kid"] = key.kid; + if (key.use !== undefined && key.use.length > 0) ordered["use"] = key.use; + if (key.key_ops !== undefined) ordered["key_ops"] = key.key_ops; + if (key.alg !== undefined && key.alg.length > 0) ordered["alg"] = key.alg; + if (key.ext !== undefined) ordered["ext"] = key.ext; + if (key.n !== undefined && key.n.length > 0) ordered["n"] = key.n; + if (key.e !== undefined && key.e.length > 0) ordered["e"] = key.e; + if (key.d !== undefined && key.d.length > 0) ordered["d"] = key.d; + if (key.p !== undefined && key.p.length > 0) ordered["p"] = key.p; + if (key.q !== undefined && key.q.length > 0) ordered["q"] = key.q; + if (key.dp !== undefined && key.dp.length > 0) ordered["dp"] = key.dp; + if (key.dq !== undefined && key.dq.length > 0) ordered["dq"] = key.dq; + if (key.qi !== undefined && key.qi.length > 0) ordered["qi"] = key.qi; + if (key.crv !== undefined && key.crv.length > 0) ordered["crv"] = key.crv; + if (key.x !== undefined && key.x.length > 0) ordered["x"] = key.x; + if (key.y !== undefined && key.y.length > 0) ordered["y"] = key.y; + return ordered; +} + +/** `filepath.Ext` (Go stdlib) — the last `.`-prefixed suffix of the final path segment, or `""` if there is none. */ +function legacyFileExt(path: string): string { + const base = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); + const dot = base.lastIndexOf("."); + return dot <= 0 ? "" : base.slice(dot); +} + +/** + * Pure GoTrue container env builder — port of Go's `buildGotrueEnv` PLUS + * every conditional env var the surrounding "Start GoTrue" block in `run()` + * appends on top of it (`start.go:629-819`). See this module's header for the + * full rationale and the `@supabase/config` schema gaps this input type works + * around. No Effect, no I/O — every field is a plain, already-resolved value. + */ +export function buildLegacyGotrueEnv(input: LegacyBuildGotrueEnvInput): Record { + const authExternalUrl = + input.authExternalUrl !== undefined && input.authExternalUrl.length > 0 + ? input.authExternalUrl + : `${legacyTrimTrailingSlashes(input.apiUrl)}/auth/v1`; + const jwtIssuer = + input.jwtIssuer !== undefined && input.jwtIssuer.length > 0 ? input.jwtIssuer : authExternalUrl; + const mailerVerifyUrl = `${legacyTrimTrailingSlashes(authExternalUrl)}/verify`; + const testOtp = + input.sms.test_otp !== undefined ? legacyFormatMapForEnvConfig(input.sms.test_otp) : ""; + + const env: Record = { + API_EXTERNAL_URL: authExternalUrl, + + GOTRUE_API_HOST: "0.0.0.0", + GOTRUE_API_PORT: LEGACY_GOTRUE_PORT, + + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_DB_DATABASE_URL: legacyStartInternalDbUrl( + LEGACY_GOTRUE_DB_ROLE, + input.dbHost, + input.dbPassword, + ), + + GOTRUE_SITE_URL: input.siteUrl, + GOTRUE_URI_ALLOW_LIST: input.additionalRedirectUrls.join(","), + GOTRUE_DISABLE_SIGNUP: String(!input.enableSignup), + + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + GOTRUE_JWT_EXP: String(input.jwtExpiry), + GOTRUE_JWT_SECRET: input.jwtSecret, + GOTRUE_JWT_ISSUER: jwtIssuer, + + GOTRUE_EXTERNAL_EMAIL_ENABLED: String(input.email.enable_signup), + GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: String(input.email.double_confirm_changes), + GOTRUE_MAILER_AUTOCONFIRM: String(!input.email.enable_confirmations), + GOTRUE_MAILER_OTP_LENGTH: String(input.email.otp_length), + GOTRUE_MAILER_OTP_EXP: String(input.email.otp_expiry), + GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED: "true", + + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: String(input.enableAnonymousSignIns), + + GOTRUE_SMTP_MAX_FREQUENCY: legacyFormatGoDuration( + legacyParseGoDuration(input.email.max_frequency), + ), + + GOTRUE_MAILER_URLPATHS_INVITE: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_RECOVERY: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: mailerVerifyUrl, + GOTRUE_RATE_LIMIT_EMAIL_SENT: "360000", + + GOTRUE_EXTERNAL_PHONE_ENABLED: String(input.sms.enable_signup), + GOTRUE_SMS_AUTOCONFIRM: String(!input.sms.enable_confirmations), + GOTRUE_SMS_MAX_FREQUENCY: legacyFormatGoDuration( + legacyParseGoDuration(input.sms.max_frequency), + ), + GOTRUE_SMS_OTP_EXP: "6000", + GOTRUE_SMS_OTP_LENGTH: "6", + GOTRUE_SMS_TEMPLATE: input.sms.template, + GOTRUE_SMS_TEST_OTP: testOtp, + + GOTRUE_PASSWORD_MIN_LENGTH: String(input.minimumPasswordLength), + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: legacyGotruePasswordRequirementsToChar( + input.passwordRequirements, + ), + GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: String(input.enableRefreshTokenRotation), + GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: String(input.refreshTokenReuseInterval), + GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: String(input.enableManualLinking), + GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: String( + input.email.secure_password_change, + ), + GOTRUE_MFA_PHONE_ENROLL_ENABLED: String(input.mfa.phone.enroll_enabled), + GOTRUE_MFA_PHONE_VERIFY_ENABLED: String(input.mfa.phone.verify_enabled), + GOTRUE_MFA_TOTP_ENROLL_ENABLED: String(input.mfa.totp.enroll_enabled), + GOTRUE_MFA_TOTP_VERIFY_ENABLED: String(input.mfa.totp.verify_enabled), + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: String(input.mfa.web_authn.enroll_enabled), + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: String(input.mfa.web_authn.verify_enabled), + GOTRUE_MFA_MAX_ENROLLED_FACTORS: String(input.mfa.max_enrolled_factors), + + GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: String(input.rateLimit.anonymous_users), + GOTRUE_RATE_LIMIT_TOKEN_REFRESH: String(input.rateLimit.token_refresh), + GOTRUE_RATE_LIMIT_OTP: String(input.rateLimit.sign_in_sign_ups), + GOTRUE_RATE_LIMIT_VERIFY: String(input.rateLimit.token_verifications), + GOTRUE_RATE_LIMIT_SMS_SENT: String(input.rateLimit.sms_sent), + GOTRUE_RATE_LIMIT_WEB3: String(input.rateLimit.web3), + }; + + // JWT signing keys (start.go:634-640) — `json.Marshal` never fails for this + // shape, so Go's `if err == nil` guard is effectively unconditional here. + const signingKeys = input.signingKeys ?? [LEGACY_GOTRUE_DEFAULT_SIGNING_KEY]; + env["GOTRUE_JWT_KEYS"] = JSON.stringify(signingKeys.map(legacyOrderGotrueSigningKey)); + // TODO: deprecate HS256 when it's no longer supported. + // TODO: remove VALIDMETHODS after a while to avoid breaking changes. + env["GOTRUE_JWT_VALIDMETHODS"] = "HS256,RS256,ES256"; + env["GOTRUE_JWT_VALID_METHODS"] = "HS256,RS256,ES256"; + + // SMTP, else Mailpit fallback (start.go:642-659). + if (input.smtp !== undefined) { + env["GOTRUE_RATE_LIMIT_EMAIL_SENT"] = String(input.rateLimit.email_sent); + env["GOTRUE_SMTP_HOST"] = input.smtp.host; + env["GOTRUE_SMTP_PORT"] = String(input.smtp.port); + env["GOTRUE_SMTP_USER"] = input.smtp.user; + env["GOTRUE_SMTP_PASS"] = input.smtp.pass; + env["GOTRUE_SMTP_ADMIN_EMAIL"] = input.smtp.adminEmail; + env["GOTRUE_SMTP_SENDER_NAME"] = input.smtp.senderName ?? ""; + } else if (input.mailpit !== undefined) { + env["GOTRUE_SMTP_HOST"] = input.mailpit.containerName; + env["GOTRUE_SMTP_PORT"] = "1025"; + env["GOTRUE_SMTP_ADMIN_EMAIL"] = + input.mailpit.adminEmail ?? LEGACY_GOTRUE_DEFAULT_INBUCKET_ADMIN_EMAIL; + env["GOTRUE_SMTP_SENDER_NAME"] = + input.mailpit.senderName ?? LEGACY_GOTRUE_DEFAULT_INBUCKET_SENDER_NAME; + } + + // Sessions (start.go:661-666) — only emitted when the parsed duration is + // strictly positive, matching Go's `> 0` guard on the zero `time.Duration`. + if (input.sessions?.timebox !== undefined) { + const nanoseconds = legacyParseGoDuration(input.sessions.timebox); + if (nanoseconds > 0) { + env["GOTRUE_SESSIONS_TIMEBOX"] = legacyFormatGoDuration(nanoseconds); + } + } + if (input.sessions?.inactivity_timeout !== undefined) { + const nanoseconds = legacyParseGoDuration(input.sessions.inactivity_timeout); + if (nanoseconds > 0) { + env["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"] = legacyFormatGoDuration(nanoseconds); + } + } + + // Mailer template/notification URLs and subjects (start.go:668-694). + // `subject !== undefined` matches Go's `subject *string; if subject != nil` exactly: the + // caller (`legacyResolveAuthEmail`) has already recovered the "explicit empty string" vs + // "absent" distinction from the raw document, so `undefined` here means Go's nil (omit the + // env var entirely) and `""` means an explicit blank subject (still emit it). + const addMailerEnvVars = (id: string, contentPath: string, subject: string | undefined): void => { + if (contentPath.length > 0) { + env[`GOTRUE_MAILER_TEMPLATES_${id.toUpperCase()}`] = + `http://${input.kongContainerName}:${LEGACY_GOTRUE_NGINX_TEMPLATE_SERVER_PORT}/email/${id}${legacyFileExt(contentPath)}`; + } + if (subject !== undefined) { + env[`GOTRUE_MAILER_SUBJECTS_${id.toUpperCase()}`] = subject; + } + }; + for (const [id, template] of Object.entries(input.email.template)) { + addMailerEnvVars(id, template.content_path, template.subject); + } + for (const [id, notification] of Object.entries(input.email.notification)) { + if (notification.enabled) { + env[`GOTRUE_MAILER_NOTIFICATIONS_${id.toUpperCase()}_ENABLED`] = "true"; + addMailerEnvVars(`${id}_notification`, notification.content_path, notification.subject); + } + } + + // SMS provider switch (start.go:696-735) — first enabled provider wins, in + // Go's fixed priority order; a later enabled-but-lower-priority provider is + // never inspected. + if (input.sms.twilio.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "twilio"; + env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"] = input.sms.twilio.account_sid; + env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"] = input.sms.twilio.auth_token ?? ""; + env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"] = input.sms.twilio.message_service_sid; + } else if (input.sms.twilio_verify.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "twilio_verify"; + env["GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID"] = input.sms.twilio_verify.account_sid ?? ""; + env["GOTRUE_SMS_TWILIO_VERIFY_AUTH_TOKEN"] = input.sms.twilio_verify.auth_token ?? ""; + env["GOTRUE_SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID"] = + input.sms.twilio_verify.message_service_sid ?? ""; + } else if (input.sms.messagebird.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "messagebird"; + env["GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY"] = input.sms.messagebird.access_key ?? ""; + env["GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR"] = input.sms.messagebird.originator ?? ""; + } else if (input.sms.textlocal.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "textlocal"; + env["GOTRUE_SMS_TEXTLOCAL_API_KEY"] = input.sms.textlocal.api_key ?? ""; + env["GOTRUE_SMS_TEXTLOCAL_SENDER"] = input.sms.textlocal.sender ?? ""; + } else if (input.sms.vonage.enabled) { + env["GOTRUE_SMS_PROVIDER"] = "vonage"; + env["GOTRUE_SMS_VONAGE_API_KEY"] = input.sms.vonage.api_key ?? ""; + env["GOTRUE_SMS_VONAGE_API_SECRET"] = input.sms.vonage.api_secret ?? ""; + env["GOTRUE_SMS_VONAGE_FROM"] = input.sms.vonage.from ?? ""; + } + + // CAPTCHA (start.go:737-744). + if (input.captcha !== undefined) { + env["GOTRUE_SECURITY_CAPTCHA_ENABLED"] = String(input.captcha.enabled); + env["GOTRUE_SECURITY_CAPTCHA_PROVIDER"] = input.captcha.provider ?? ""; + env["GOTRUE_SECURITY_CAPTCHA_SECRET"] = input.captcha.secret ?? ""; + } + + // Auth hooks (start.go:746-793) — each independently gated on its own `enabled`. + const appendHookEnv = (envPrefix: string, hook: LegacyGotrueHookInput): void => { + if (!hook.enabled) return; + env[`GOTRUE_HOOK_${envPrefix}_ENABLED`] = "true"; + env[`GOTRUE_HOOK_${envPrefix}_URI`] = hook.uri ?? ""; + env[`GOTRUE_HOOK_${envPrefix}_SECRETS`] = hook.secrets ?? ""; + }; + appendHookEnv("MFA_VERIFICATION_ATTEMPT", input.hooks.mfaVerificationAttempt); + appendHookEnv("PASSWORD_VERIFICATION_ATTEMPT", input.hooks.passwordVerificationAttempt); + appendHookEnv("CUSTOM_ACCESS_TOKEN", input.hooks.customAccessToken); + appendHookEnv("SEND_SMS", input.hooks.sendSms); + appendHookEnv("SEND_EMAIL", input.hooks.sendEmail); + appendHookEnv("BEFORE_USER_CREATED", input.hooks.beforeUserCreated); + + // MFA phone extras (start.go:795-802) — only when phone MFA is reachable at all. + if (input.mfa.phone.enroll_enabled || input.mfa.phone.verify_enabled) { + env["GOTRUE_MFA_PHONE_TEMPLATE"] = input.mfa.phone.template; + env["GOTRUE_MFA_PHONE_OTP_LENGTH"] = String(input.mfa.phone.otp_length); + env["GOTRUE_MFA_PHONE_MAX_FREQUENCY"] = legacyFormatGoDuration( + legacyParseGoDuration(input.mfa.phone.max_frequency), + ); + } + + // Passkey/WebAuthn, then external OAuth providers (start.go:804-805). + legacyAppendGotruePasskeyEnv(env, input.passkeyEnabled, input.webauthn); + legacyAppendGotrueExternalProviderEnv(env, input.externalProviders, jwtIssuer); + + // Web3 (start.go:806-809) — always emitted, unconditionally. + env["GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED"] = String(input.web3.solana.enabled); + env["GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED"] = String(input.web3.ethereum.enabled); + + // OAuth server configuration (start.go:811-818). + if (input.oauthServer.enabled) { + env["GOTRUE_OAUTH_SERVER_ENABLED"] = String(input.oauthServer.enabled); + env["GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH"] = input.oauthServer.authorization_url_path; + env["GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"] = String( + input.oauthServer.allow_dynamic_registration, + ); + } + + return env; +} + +export interface LegacyGotrueContainerSpecInput { + /** `container.Config.Image` — the already-resolved `config.auth.image`. Not part of the decoded `@supabase/config` schema (Go's own `Auth.Image` field is `toml:"-"`); resolution is the caller's responsibility. */ + readonly image: string; + /** Go's `Config.ProjectId`, used to derive `utils.GotrueId`/the `db` container's own name via {@link legacyServiceContainerName}. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`'s target — resolved once per `start` run, not per-container. */ + readonly networkId: string; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password (see {@link legacyStartInternalDbPassword}). */ + readonly dbUrl: string; + /** Every value {@link buildLegacyGotrueEnv} needs, minus the two this builder derives itself (`dbHost`/`dbPassword`). */ + readonly env: Omit; +} + +/** + * Builds the `docker create` spec for the GoTrue/Auth container + * (`start.go:820-850`). No `ports` (host-published) entry — GoTrue, like + * Realtime, only ever `ExposedPorts` its port on the Docker network. + */ +export function buildLegacyGotrueContainerSpec( + input: LegacyGotrueContainerSpecInput, +): LegacyStartContainerSpec { + const dbHost = legacyServiceContainerName("db", input.projectId); + const dbPassword = legacyStartInternalDbPassword(input.dbUrl); + const env = buildLegacyGotrueEnv({ ...input.env, dbHost, dbPassword }); + + return { + image: input.image, + containerName: legacyServiceContainerName(LEGACY_GOTRUE_CONTAINER_SUFFIX, input.projectId), + env, + binds: [], + exposedPorts: [{ containerPort: LEGACY_GOTRUE_PORT }], + healthcheck: { + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + `http://127.0.0.1:${LEGACY_GOTRUE_PORT}/health`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: [LEGACY_GOTRUE_CONTAINER_SUFFIX], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts new file mode 100644 index 0000000000..5f5be412dc --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.unit.test.ts @@ -0,0 +1,704 @@ +import { describe, expect, test } from "vitest"; + +import { + buildLegacyGotrueContainerSpec, + buildLegacyGotrueEnv, + legacyFormatMapForEnvConfig, + type LegacyBuildGotrueEnvInput, + type LegacyGotrueExternalProviderInput, + type LegacyGotrueHookInput, + type LegacyGotrueSigningKey, + type LegacyGotrueWebauthnInput, +} from "./gotrue.service.ts"; + +// Mirrors the fixture Go's `TestBuildGotrueEnv` builds via `config.NewConfig()` +// (`apps/cli-go/internal/start/start_test.go:440-520`), translated into this +// pure function's explicit input shape. Every field not asserted by a +// specific Go subtest below reflects `config.NewConfig()`'s own defaults. +const baseEnvInput: LegacyBuildGotrueEnvInput = { + dbHost: "db", + dbPassword: "postgres", + apiUrl: "http://127.0.0.1:54321", + jwtSecret: "test-jwt-secret", + jwtIssuer: undefined, + jwtExpiry: 3600, + siteUrl: "http://127.0.0.1:3000", + additionalRedirectUrls: ["https://127.0.0.1:3000"], + enableSignup: true, + enableAnonymousSignIns: false, + enableRefreshTokenRotation: true, + refreshTokenReuseInterval: 10, + enableManualLinking: false, + minimumPasswordLength: 6, + passwordRequirements: "", + email: { + enable_signup: true, + double_confirm_changes: true, + enable_confirmations: false, + secure_password_change: false, + max_frequency: "1s", + otp_length: 6, + otp_expiry: 3600, + template: {}, + notification: {}, + }, + kongContainerName: "test-kong", + sms: { + enable_signup: false, + enable_confirmations: false, + template: "Your code is {{ .Code }}", + max_frequency: "5s", + twilio: { enabled: false, account_sid: "", message_service_sid: "" }, + twilio_verify: { enabled: false }, + messagebird: { enabled: false }, + textlocal: { enabled: false }, + vonage: { enabled: false }, + }, + sessions: undefined, + mfa: { + totp: { enroll_enabled: false, verify_enabled: false }, + phone: { + enroll_enabled: false, + verify_enabled: false, + otp_length: 6, + template: "Your code is {{ .Code }}", + max_frequency: "5s", + }, + web_authn: { enroll_enabled: false, verify_enabled: false }, + max_enrolled_factors: 10, + }, + rateLimit: { + anonymous_users: 30, + token_refresh: 150, + sign_in_sign_ups: 30, + token_verifications: 30, + email_sent: 2, + sms_sent: 30, + web3: 30, + }, + web3: { solana: { enabled: false }, ethereum: { enabled: false } }, + oauthServer: { + enabled: false, + authorization_url_path: "/oauth/consent", + allow_dynamic_registration: false, + }, + hooks: { + mfaVerificationAttempt: { enabled: false }, + passwordVerificationAttempt: { enabled: false }, + customAccessToken: { enabled: false }, + sendSms: { enabled: false }, + sendEmail: { enabled: false }, + beforeUserCreated: { enabled: false }, + }, + externalProviders: {}, +}; + +describe("buildLegacyGotrueEnv", () => { + // Port of TestBuildGotrueEnv's 4 sub-cases (start_test.go:440-520). + describe("TestBuildGotrueEnv parity", () => { + test("uses auth scoped external url and absolute mailer verify urls", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + apiUrl: "http://127.0.0.1:54321", + jwtIssuer: undefined, + siteUrl: "http://127.0.0.1:3000", + externalProviders: { + github: { + enabled: true, + clientId: "client-id", + secret: "secret", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(env["GOTRUE_JWT_ISSUER"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(env["GOTRUE_MAILER_URLPATHS_INVITE"]).toBe("http://127.0.0.1:54321/auth/v1/verify"); + expect(env["GOTRUE_MAILER_URLPATHS_CONFIRMATION"]).toBe( + "http://127.0.0.1:54321/auth/v1/verify", + ); + expect(env["GOTRUE_MAILER_URLPATHS_RECOVERY"]).toBe("http://127.0.0.1:54321/auth/v1/verify"); + expect(env["GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE"]).toBe( + "http://127.0.0.1:54321/auth/v1/verify", + ); + expect(env["GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI"]).toBe( + "http://127.0.0.1:54321/auth/v1/callback", + ); + }); + + test("honors an explicit auth.external_url override for API_EXTERNAL_URL, the JWT issuer default, the mailer verify URL, and OAuth redirects", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + apiUrl: "http://127.0.0.1:54321", + authExternalUrl: "https://auth.example.com", + jwtIssuer: undefined, + siteUrl: "http://127.0.0.1:3000", + externalProviders: { + github: { + enabled: true, + clientId: "client-id", + secret: "secret", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["API_EXTERNAL_URL"]).toBe("https://auth.example.com"); + expect(env["GOTRUE_JWT_ISSUER"]).toBe("https://auth.example.com"); + expect(env["GOTRUE_MAILER_URLPATHS_INVITE"]).toBe("https://auth.example.com/verify"); + expect(env["GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI"]).toBe("https://auth.example.com/callback"); + }); + + test("preserves explicit provider redirect override", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + apiUrl: "http://127.0.0.1:54321", + jwtIssuer: "https://issuer.example.com/auth/v1", + siteUrl: "http://127.0.0.1:3000", + externalProviders: { + azure: { + enabled: true, + clientId: "", + url: "", + redirectUri: "https://example.com/custom/callback", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["API_EXTERNAL_URL"]).toBe("http://127.0.0.1:54321/auth/v1"); + expect(env["GOTRUE_JWT_ISSUER"]).toBe("https://issuer.example.com/auth/v1"); + expect(env["GOTRUE_MAILER_URLPATHS_INVITE"]).toBe("http://127.0.0.1:54321/auth/v1/verify"); + expect(env["GOTRUE_EXTERNAL_AZURE_REDIRECT_URI"]).toBe("https://example.com/custom/callback"); + }); + + test("wires passkey and webauthn settings", () => { + const webauthn: LegacyGotrueWebauthnInput = { + rpId: "localhost", + rpDisplayName: "Supabase", + rpOrigins: ["http://127.0.0.1:5173", "http://localhost:5173"], + }; + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + passkeyEnabled: true, + webauthn, + }); + + expect(env["GOTRUE_PASSKEY_ENABLED"]).toBe("true"); + expect(env["GOTRUE_WEBAUTHN_RP_ID"]).toBe("localhost"); + expect(env["GOTRUE_WEBAUTHN_RP_DISPLAY_NAME"]).toBe("Supabase"); + expect(env["GOTRUE_WEBAUTHN_RP_ORIGINS"]).toBe("http://127.0.0.1:5173,http://localhost:5173"); + }); + + test("omits passkey and webauthn env when sections are unset", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + passkeyEnabled: undefined, + webauthn: undefined, + }); + + expect(env["GOTRUE_PASSKEY_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_WEBAUTHN_RP_ID"]).toBeUndefined(); + }); + }); + + // Port of TestFormatMapForEnvConfig (start_test.go:566-612), exercised + // through GOTRUE_SMS_TEST_OTP since `formatMapForEnvConfig` is only ever + // called from inside `buildGotrueEnv`. + describe("GOTRUE_SMS_TEST_OTP / formatMapForEnvConfig parity", () => { + test("legacyFormatMapForEnvConfig produces key:value pairs with no trailing comma", () => { + expect(legacyFormatMapForEnvConfig({})).toBe(""); + expect(legacyFormatMapForEnvConfig({ "123456": "123456" })).toMatch(/^\w{6}:\w{6}$/); + expect(legacyFormatMapForEnvConfig({ "123456": "123456", "234567": "234567" })).toMatch( + /^\w{6}:\w{6},\w{6}:\w{6}$/, + ); + }); + + test("defaults GOTRUE_SMS_TEST_OTP to an empty string when auth.sms.test_otp is unset", () => { + const env = buildLegacyGotrueEnv({ ...baseEnvInput, sms: { ...baseEnvInput.sms } }); + expect(env["GOTRUE_SMS_TEST_OTP"]).toBe(""); + }); + + test("formats a single-entry auth.sms.test_otp map", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + sms: { ...baseEnvInput.sms, test_otp: { "5555555555": "123456" } }, + }); + expect(env["GOTRUE_SMS_TEST_OTP"]).toBe("5555555555:123456"); + }); + + test("formats a multi-entry auth.sms.test_otp map with commas and no trailing comma", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + sms: { + ...baseEnvInput.sms, + test_otp: { "5555555555": "123456", "4444444444": "654321" }, + }, + }); + expect(env["GOTRUE_SMS_TEST_OTP"]).not.toMatch(/,$/); + expect(env["GOTRUE_SMS_TEST_OTP"]?.split(",").sort()).toEqual( + ["5555555555:123456", "4444444444:654321"].sort(), + ); + }); + }); + + describe("external OAuth providers", () => { + test("a simple enabled provider (github) emits the full env set, with URL omitted when unset", () => { + const github: LegacyGotrueExternalProviderInput = { + enabled: true, + clientId: "gh-client-id", + secret: "gh-secret", + url: "", + skipNonceCheck: false, + emailOptional: true, + }; + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + externalProviders: { github }, + }); + + expect(env["GOTRUE_EXTERNAL_GITHUB_ENABLED"]).toBe("true"); + expect(env["GOTRUE_EXTERNAL_GITHUB_CLIENT_ID"]).toBe("gh-client-id"); + expect(env["GOTRUE_EXTERNAL_GITHUB_SECRET"]).toBe("gh-secret"); + expect(env["GOTRUE_EXTERNAL_GITHUB_SKIP_NONCE_CHECK"]).toBe("false"); + expect(env["GOTRUE_EXTERNAL_GITHUB_EMAIL_OPTIONAL"]).toBe("true"); + expect(env["GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI"]).toBe( + "http://127.0.0.1:54321/auth/v1/callback", + ); + expect(env["GOTRUE_EXTERNAL_GITHUB_URL"]).toBeUndefined(); + }); + + test("a provider with a configured base url (keycloak) emits GOTRUE_EXTERNAL__URL", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + externalProviders: { + keycloak: { + enabled: true, + clientId: "kc-client-id", + secret: "kc-secret", + url: "https://keycloak.example.com/realms/myrealm", + skipNonceCheck: true, + emailOptional: false, + }, + }, + }); + + expect(env["GOTRUE_EXTERNAL_KEYCLOAK_URL"]).toBe( + "https://keycloak.example.com/realms/myrealm", + ); + expect(env["GOTRUE_EXTERNAL_KEYCLOAK_SKIP_NONCE_CHECK"]).toBe("true"); + }); + + test("emits full env for a configured-but-disabled provider (Go has no `if config.Enabled` gate)", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + externalProviders: { + apple: { + enabled: false, + clientId: "", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + }); + + expect(env["GOTRUE_EXTERNAL_APPLE_ENABLED"]).toBe("false"); + expect(env["GOTRUE_EXTERNAL_APPLE_CLIENT_ID"]).toBe(""); + expect(env["GOTRUE_EXTERNAL_APPLE_SECRET"]).toBe(""); + }); + + test("omits any env for a provider absent from the (caller-presence-filtered) input map", () => { + const env = buildLegacyGotrueEnv({ ...baseEnvInput, externalProviders: {} }); + + expect(env["GOTRUE_EXTERNAL_GITHUB_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_EXTERNAL_APPLE_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_EXTERNAL_AZURE_ENABLED"]).toBeUndefined(); + }); + }); + + describe("SMTP / Mailpit fallback", () => { + test("uses configured SMTP when present, overriding the hardcoded GOTRUE_RATE_LIMIT_EMAIL_SENT default", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + rateLimit: { ...baseEnvInput.rateLimit, email_sent: 99 }, + smtp: { + host: "smtp.example.com", + port: 587, + user: "smtp-user", + pass: "smtp-pass", + adminEmail: "admin@example.com", + senderName: "Example", + }, + }); + + expect(env["GOTRUE_SMTP_HOST"]).toBe("smtp.example.com"); + expect(env["GOTRUE_SMTP_PORT"]).toBe("587"); + expect(env["GOTRUE_SMTP_USER"]).toBe("smtp-user"); + expect(env["GOTRUE_SMTP_PASS"]).toBe("smtp-pass"); + expect(env["GOTRUE_SMTP_ADMIN_EMAIL"]).toBe("admin@example.com"); + expect(env["GOTRUE_SMTP_SENDER_NAME"]).toBe("Example"); + expect(env["GOTRUE_RATE_LIMIT_EMAIL_SENT"]).toBe("99"); + }); + + test("falls back to Mailpit when SMTP is unset and local_smtp is enabled", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + mailpit: { containerName: "supabase_inbucket_proj" }, + }); + + expect(env["GOTRUE_SMTP_HOST"]).toBe("supabase_inbucket_proj"); + expect(env["GOTRUE_SMTP_PORT"]).toBe("1025"); + expect(env["GOTRUE_SMTP_ADMIN_EMAIL"]).toBe("admin@email.com"); + expect(env["GOTRUE_SMTP_SENDER_NAME"]).toBe("Admin"); + expect(env["GOTRUE_RATE_LIMIT_EMAIL_SENT"]).toBe("360000"); + }); + + test("emits neither SMTP nor Mailpit env when both are unset", () => { + const env = buildLegacyGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SMTP_HOST"]).toBeUndefined(); + expect(env["GOTRUE_RATE_LIMIT_EMAIL_SENT"]).toBe("360000"); + }); + }); + + describe("sessions", () => { + test("omits GOTRUE_SESSIONS_TIMEBOX/INACTIVITY_TIMEOUT when unset", () => { + const env = buildLegacyGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SESSIONS_TIMEBOX"]).toBeUndefined(); + expect(env["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBeUndefined(); + }); + + test("reformats a configured duration into Go's canonical Duration.String() form", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + sessions: { timebox: "1h", inactivity_timeout: "90s" }, + }); + expect(env["GOTRUE_SESSIONS_TIMEBOX"]).toBe("1h0m0s"); + expect(env["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBe("1m30s"); + }); + + test("omits a configured but zero-valued duration, matching Go's `> 0` guard", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + sessions: { timebox: "0s" }, + }); + expect(env["GOTRUE_SESSIONS_TIMEBOX"]).toBeUndefined(); + }); + }); + + describe("SMS provider switch", () => { + test("twilio takes priority when multiple providers are enabled", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + sms: { + ...baseEnvInput.sms, + twilio: { + enabled: true, + account_sid: "AC123", + message_service_sid: "MG123", + auth_token: "token123", + }, + vonage: { enabled: true, from: "vonage-from", api_key: "k", api_secret: "s" }, + }, + }); + + expect(env["GOTRUE_SMS_PROVIDER"]).toBe("twilio"); + expect(env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"]).toBe("AC123"); + expect(env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"]).toBe("token123"); + expect(env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"]).toBe("MG123"); + expect(env["GOTRUE_SMS_VONAGE_API_KEY"]).toBeUndefined(); + }); + + test("vonage is used when it is the only enabled provider", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + sms: { + ...baseEnvInput.sms, + vonage: { enabled: true, from: "vonage-from", api_key: "k", api_secret: "s" }, + }, + }); + + expect(env["GOTRUE_SMS_PROVIDER"]).toBe("vonage"); + expect(env["GOTRUE_SMS_VONAGE_FROM"]).toBe("vonage-from"); + expect(env["GOTRUE_SMS_VONAGE_API_KEY"]).toBe("k"); + expect(env["GOTRUE_SMS_VONAGE_API_SECRET"]).toBe("s"); + }); + + test("omits GOTRUE_SMS_PROVIDER when no provider is enabled", () => { + const env = buildLegacyGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SMS_PROVIDER"]).toBeUndefined(); + }); + }); + + describe("CAPTCHA", () => { + test("emits CAPTCHA env when present", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + captcha: { enabled: true, provider: "hcaptcha", secret: "captcha-secret" }, + }); + expect(env["GOTRUE_SECURITY_CAPTCHA_ENABLED"]).toBe("true"); + expect(env["GOTRUE_SECURITY_CAPTCHA_PROVIDER"]).toBe("hcaptcha"); + expect(env["GOTRUE_SECURITY_CAPTCHA_SECRET"]).toBe("captcha-secret"); + }); + + test("omits CAPTCHA env when unset", () => { + const env = buildLegacyGotrueEnv(baseEnvInput); + expect(env["GOTRUE_SECURITY_CAPTCHA_ENABLED"]).toBeUndefined(); + }); + }); + + describe("hooks", () => { + test("emits ENABLED/URI/SECRETS for each enabled hook, omits disabled ones", () => { + const customAccessToken: LegacyGotrueHookInput = { + enabled: true, + uri: "pg-functions://postgres/public/custom_access_token_hook", + secrets: "hook-secret", + }; + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + hooks: { ...baseEnvInput.hooks, customAccessToken }, + }); + + expect(env["GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]).toBe("true"); + expect(env["GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI"]).toBe( + "pg-functions://postgres/public/custom_access_token_hook", + ); + expect(env["GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]).toBe("hook-secret"); + expect(env["GOTRUE_HOOK_SEND_EMAIL_ENABLED"]).toBeUndefined(); + }); + }); + + describe("MFA phone extras", () => { + test("emits template/otp_length/max_frequency when phone enrollment is enabled", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + mfa: { + ...baseEnvInput.mfa, + phone: { ...baseEnvInput.mfa.phone, enroll_enabled: true, max_frequency: "10s" }, + }, + }); + expect(env["GOTRUE_MFA_PHONE_TEMPLATE"]).toBe("Your code is {{ .Code }}"); + expect(env["GOTRUE_MFA_PHONE_OTP_LENGTH"]).toBe("6"); + expect(env["GOTRUE_MFA_PHONE_MAX_FREQUENCY"]).toBe("10s"); + }); + + test("omits phone extras when neither enroll nor verify is enabled", () => { + const env = buildLegacyGotrueEnv(baseEnvInput); + expect(env["GOTRUE_MFA_PHONE_TEMPLATE"]).toBeUndefined(); + expect(env["GOTRUE_MFA_PHONE_MAX_FREQUENCY"]).toBeUndefined(); + }); + }); + + describe("mailer templates and notifications", () => { + test("emits a template URL and subject, using the content path's extension", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + template: { + confirmation: { + subject: "Confirm your signup", + content_path: "supabase/templates/confirm.html", + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_TEMPLATES_CONFIRMATION"]).toBe( + "http://test-kong:8088/email/confirmation.html", + ); + expect(env["GOTRUE_MAILER_SUBJECTS_CONFIRMATION"]).toBe("Confirm your signup"); + }); + + // Go's `emailTemplate.Subject` is `*string` (`pkg/config/auth.go:266`); `start.go:668-676` + // gates strictly on `subject != nil`, not on string length — an explicit blank subject is + // still emitted, distinct from an absent one below. + test("still emits an explicit empty subject, distinct from an absent one", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + template: { + confirmation: { subject: "", content_path: "supabase/templates/confirm.html" }, + }, + }, + }); + expect(env["GOTRUE_MAILER_SUBJECTS_CONFIRMATION"]).toBe(""); + }); + + test("omits the subject env var entirely when subject is absent (undefined)", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + template: { + confirmation: { subject: undefined, content_path: "supabase/templates/confirm.html" }, + }, + }, + }); + expect(env).not.toHaveProperty("GOTRUE_MAILER_SUBJECTS_CONFIRMATION"); + }); + + test("emits a notification's ENABLED flag and template/subject with the _notification suffix", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + notification: { + password_changed: { + enabled: true, + subject: "Your password changed", + content_path: "supabase/templates/password_changed.html", + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED"]).toBe("true"); + expect(env["GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION"]).toBe( + "http://test-kong:8088/email/password_changed_notification.html", + ); + expect(env["GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION"]).toBe( + "Your password changed", + ); + }); + + test("omits a disabled notification's env entirely", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + email: { + ...baseEnvInput.email, + notification: { + password_changed: { + enabled: false, + subject: "Your password changed", + content_path: "supabase/templates/password_changed.html", + }, + }, + }, + }); + expect(env["GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED"]).toBeUndefined(); + expect(env["GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION"]).toBeUndefined(); + }); + }); + + describe("web3 and OAuth server", () => { + test("always emits both Web3 flags, regardless of value", () => { + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + web3: { solana: { enabled: true }, ethereum: { enabled: false } }, + }); + expect(env["GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED"]).toBe("true"); + expect(env["GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED"]).toBe("false"); + }); + + test("emits OAuth server env only when enabled", () => { + const disabled = buildLegacyGotrueEnv(baseEnvInput); + expect(disabled["GOTRUE_OAUTH_SERVER_ENABLED"]).toBeUndefined(); + + const enabled = buildLegacyGotrueEnv({ + ...baseEnvInput, + oauthServer: { + enabled: true, + authorization_url_path: "/oauth/consent", + allow_dynamic_registration: true, + }, + }); + expect(enabled["GOTRUE_OAUTH_SERVER_ENABLED"]).toBe("true"); + expect(enabled["GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH"]).toBe("/oauth/consent"); + expect(enabled["GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"]).toBe("true"); + }); + }); + + describe("password requirements", () => { + test.each([ + ["", ""], + ["letters_digits", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"], + [ + "lower_upper_letters_digits", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + ], + ] as const)("%s -> %s", (passwordRequirements, expected) => { + const env = buildLegacyGotrueEnv({ ...baseEnvInput, passwordRequirements }); + expect(env["GOTRUE_PASSWORD_REQUIRED_CHARACTERS"]).toBe(expected); + }); + }); + + describe("JWT signing keys", () => { + test("defaults to Go's hardcoded ES256 signing key when unset", () => { + const env = buildLegacyGotrueEnv(baseEnvInput); + const keys = JSON.parse(env["GOTRUE_JWT_KEYS"] as string); + expect(keys).toEqual([ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["sign", "verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }, + ]); + expect(env["GOTRUE_JWT_VALIDMETHODS"]).toBe("HS256,RS256,ES256"); + expect(env["GOTRUE_JWT_VALID_METHODS"]).toBe("HS256,RS256,ES256"); + }); + + test("serializes a configured signing key, omitting unset fields", () => { + const rsaKey: LegacyGotrueSigningKey = { kty: "RSA", alg: "RS256", n: "modulus", e: "AQAB" }; + const env = buildLegacyGotrueEnv({ + ...baseEnvInput, + signingKeys: [rsaKey], + }); + expect(JSON.parse(env["GOTRUE_JWT_KEYS"] as string)).toEqual([ + { kty: "RSA", alg: "RS256", n: "modulus", e: "AQAB" }, + ]); + }); + }); +}); + +describe("buildLegacyGotrueContainerSpec", () => { + test("assembles the full container spec, deriving dbHost/dbPassword from projectId/dbUrl", () => { + const spec = buildLegacyGotrueContainerSpec({ + image: "supabase/gotrue:v2.180.0", + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres", + env: baseEnvInput, + }); + + expect(spec.image).toBe("supabase/gotrue:v2.180.0"); + expect(spec.containerName).toBe("supabase_auth_proj"); + expect(spec.binds).toEqual([]); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toEqual([{ containerPort: "9999" }]); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:9999/health", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.networkAliases).toEqual(["auth"]); + expect(spec.labels).toEqual({}); + + // dbHost/dbPassword flow from projectId/dbUrl into the env's connection string. + expect(spec.env["GOTRUE_DB_DATABASE_URL"]).toBe( + "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts b/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts new file mode 100644 index 0000000000..69c9a32831 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts @@ -0,0 +1,80 @@ +/** + * Port of Go's "Start Storage ImgProxy" block + * (`apps/cli-go/internal/start/start.go:1059-1099`). + * + * Enabled gate: `isStorageEnabled && isImgProxyEnabled` (`start.go:1060`) — + * i.e. `config.storage.enabled && config.storage.image_transformation?.enabled + * && !isContainerExcluded(imgproxyImage, excluded)`, AND Storage itself must + * be enabled (ImgProxy mounts Storage's own volumes via `VolumesFrom` below, + * so it cannot meaningfully run without it). Gating the actual container + * start is the future `start.handler.ts` orchestrator's responsibility — + * see `start.services.ts`'s `imgproxy` catalog entry (`enabledGate: + * "storage.enabled && storage.image_transformation.enabled"`, `dependsOn: + * ["storage"]`) — this module only builds the container spec once called. + * The caller must pass the SAME `isImgProxyEnabled` boolean it used for this + * gating decision into `storage.service.ts`'s `LegacyStorageEnvInput. + * imageTransformationEnabled` — see that file's header. + */ + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** + * Go's `Env` literal (`start.go:1065-1075`) — entirely static, no + * `config.toml` field feeds any of these values. + */ +export function legacyBuildImgproxyEnv(): Record { + return { + IMGPROXY_BIND: ":5001", + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + // Reproduced verbatim: Go's own literal is `"/"`, not a boolean-looking + // value (`start.go:1068`) — not "fixed" here, matching Go exactly. + IMGPROXY_USE_ETAG: "/", + IMGPROXY_MAX_SRC_RESOLUTION: "50", + IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", + IMGPROXY_MAX_ANIMATION_FRAMES: "60", + IMGPROXY_ENABLE_WEBP_DETECTION: "true", + IMGPROXY_PRESETS: "default=width:3000/height:8192", + IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", + }; +} + +export interface LegacyImgproxyContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Storage.ImgProxyImage`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; +} + +/** + * Builds the `docker create` spec for the ImgProxy container + * (`start.go:1059-1099`). `volumesFrom` mounts Storage's own volumes + * (`container.HostConfig.VolumesFrom: []string{utils.StorageId}`, + * `start.go:1084`) — no `ports`/`exposedPorts`; ImgProxy is reached only via + * its Docker network alias, from Storage's own `IMGPROXY_URL` env var + * (`storage.service.ts`'s `legacyBuildStorageEnv`). + */ +export function legacyBuildImgproxyContainerSpec( + input: LegacyImgproxyContainerSpecInput, +): LegacyStartContainerSpec { + return { + image: input.image, + containerName: legacyServiceContainerName("imgproxy", input.projectId), + env: legacyBuildImgproxyEnv(), + binds: [], + volumesFrom: [legacyServiceContainerName("storage", input.projectId)], + healthcheck: { + test: ["CMD", "imgproxy", "health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.ImgProxyAliases = []string{"imgproxy"}` (`utils/config.go:43`). + networkAliases: ["imgproxy"], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/imgproxy.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/imgproxy.service.unit.test.ts new file mode 100644 index 0000000000..4d13b0bb12 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/imgproxy.service.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildImgproxyContainerSpec, + legacyBuildImgproxyEnv, + type LegacyImgproxyContainerSpecInput, +} from "./imgproxy.service.ts"; + +describe("legacyBuildImgproxyEnv", () => { + test("matches Go's fully static Env literal, including the literal (non-boolean) IMGPROXY_USE_ETAG value", () => { + expect(legacyBuildImgproxyEnv()).toEqual({ + IMGPROXY_BIND: ":5001", + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + IMGPROXY_USE_ETAG: "/", + IMGPROXY_MAX_SRC_RESOLUTION: "50", + IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", + IMGPROXY_MAX_ANIMATION_FRAMES: "60", + IMGPROXY_ENABLE_WEBP_DETECTION: "true", + IMGPROXY_PRESETS: "default=width:3000/height:8192", + IMGPROXY_FORMAT_QUALITY: "jpeg=80,avif=62,webp=80", + }); + }); +}); + +describe("legacyBuildImgproxyContainerSpec", () => { + const input: LegacyImgproxyContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/imgproxy:v3", + }; + + test("derives its own container name and mounts Storage's volumes via VolumesFrom", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.containerName).toBe("supabase_imgproxy_proj"); + expect(spec.volumesFrom).toEqual(["supabase_storage_proj"]); + expect(spec.binds).toEqual([]); + }); + + test("has no ports/exposedPorts — reached only via its network alias", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toBeUndefined(); + }); + + test("builds the imgproxy-native healthcheck", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "imgproxy", "health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("network alias is 'imgproxy'", () => { + const spec = legacyBuildImgproxyContainerSpec(input); + expect(spec.networkAliases).toEqual(["imgproxy"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("derives the storage volume-source name from a different projectId", () => { + const spec = legacyBuildImgproxyContainerSpec({ ...input, projectId: "other" }); + expect(spec.containerName).toBe("supabase_imgproxy_other"); + expect(spec.volumesFrom).toEqual(["supabase_storage_other"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.ts b/apps/cli/src/legacy/commands/start/services/kong.service.ts new file mode 100644 index 0000000000..d6c081cc17 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/kong.service.ts @@ -0,0 +1,337 @@ +/** + * Kong container spec builder — port of Go's "Start Kong" block + * (`apps/cli-go/internal/start/start.go:486-627`), gated on + * `!isContainerExcluded(config.api.kong_image, excluded)` (Kong has no + * `enabled` flag of its own — it is the stack's mandatory gateway) — see + * `legacy-service-catalog.ts`'s `kong` entry (`excludeKey: "kong"`). Gating and + * image resolution/pre-pull are the caller's job (a future `start.handler.ts`); + * this module only assembles the container spec once the caller has already + * decided to start it, matching `docker-create-args.ts`'s "image already + * resolved/pulled" contract. + * + * How Go injects `kong.yml`/`custom_nginx.template`/the TLS cert+key into the + * container (`start.go:588-601`): a single `sh -c` entrypoint script with + * FOUR chained `cat <<'EOF' > && \` heredocs (joined by shell + * line-continuation into one logical command line), all four bodies — + * including `kong.yml`'s embedded service-role-key-derived bearer/query + * tokens and the TLS private key — landing directly in the container's own + * `Cmd`. Safe for Go: it builds `container.Config` structs and calls + * `Docker.ContainerCreate` over the Engine API directly, so that `Cmd` string + * never becomes a subprocess's own argv. THIS PORT SHELLS OUT to a real + * `docker create`, so it deliberately diverges here (CWE-214/522, `ps aux`/ + * `/proc//cmdline`): `kong.yml` (the service-role key) and the TLS + * cert/key (the highest-value secret — a private key) travel via + * {@link LegacyStartContainerSpec.secretFiles} instead — a HOST temp file, + * mode `0600`, bind-mounted read-only at the exact fixed paths + * `KONG_DECLARATIVE_CONFIG`/`KONG_SSL_CERT`/`KONG_SSL_CERT_KEY` already + * reference — and never appear in this process's own argv. Only + * `custom_nginx.template`, which carries no secret content, still travels via + * the heredoc entrypoint script, matching Go exactly. + * + * The TLS cert/key `secretFiles` entries are still ALWAYS present — never a + * conditional bind — for the same reason Go always wrote them: + * `KONG_SSL_CERT`/`KONG_SSL_CERT_KEY` reference fixed in-container paths + * unconditionally. Their content is never empty either: Go's `NewConfig` + * seeds `Api.Tls.{CertContent,KeyContent}` with the embedded default + * localhost cert/key (`pkg/config/config.go:452-455`), and only overwrites + * them from disk when TLS is enabled AND both `cert_path`/`key_path` are + * configured — see {@link LegacyKongContainerSpecInput.tlsCertContent}'s doc + * comment. {@link legacyBuildKongEntrypointScript} + * reproduces the remaining `custom_nginx.template` heredoc + exec line + * byte-for-byte; see its doc comment for the exact shell mechanics. + * + * Kong mints no JWTs of its own: `BearerToken`/`QueryToken` (`start.go:501-521`) + * are Kong `request-transformer`/lua expression STRINGS built by + * `fmt.Sprintf` from the four already-generated API keys + * (`utils.Config.Auth.{SecretKey,ServiceRoleKey,PublishableKey,AnonKey}.Value` + * — see `legacy-local-config-values.ts`'s `LegacyLocalConfigValues`, which + * already resolves all four). {@link legacyBuildKongBearerToken}/ + * {@link legacyBuildKongQueryToken} reproduce those two `fmt.Sprintf` calls + * exactly; nothing in this module calls `legacyGenerateGoJwt` itself. + */ + +import * as nodePath from "node:path"; + +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; +import { legacyRenderStartKongYml } from "../lib/template-render.ts"; +import { LEGACY_START_CUSTOM_NGINX_TEMPLATE } from "../templates/custom_nginx.template.ts"; + +/** `utils.KongAliases` (`apps/cli-go/internal/utils/config.go:37`) — a fixed, non-configurable constant. */ +const LEGACY_KONG_NETWORK_ALIASES = ["kong", "api.supabase.internal"]; + +/** `nginxEmailTemplateDir` (`start.go:114`) — the fixed in-container directory email template mounts land in. */ +const LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR = "/home/kong/templates/email"; + +/** `nginxTemplateServerPort` (`start.go:115`) — the fixed port `custom_nginx.template`'s `email_templates` server listens on. */ +const LEGACY_KONG_NGINX_TEMPLATE_SERVER_PORT = 8088; + +export interface LegacyKongApiKeys { + /** `Config.Auth.SecretKey.Value`. */ + readonly secretKey: string; + /** `Config.Auth.ServiceRoleKey.Value`. */ + readonly serviceRoleKey: string; + /** `Config.Auth.PublishableKey.Value`. */ + readonly publishableKey: string; + /** `Config.Auth.AnonKey.Value`. */ + readonly anonKey: string; +} + +/** + * Go's `BearerToken` (`start.go:501-514`): a Kong `request-transformer` lua + * expression, NOT a JWT — forwards a caller's own `Bearer sb_...` Authorization + * header verbatim, otherwise maps a matching `apikey` header to the + * corresponding `Bearer ` value, falling back to echoing `apikey` as-is. + * Reproduces the `fmt.Sprintf` call exactly, including argument order + * (secretKey, serviceRoleKey, publishableKey, anonKey). + */ +export function legacyBuildKongBearerToken(apiKeys: LegacyKongApiKeys): string { + return ( + `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) ` + + `or (headers.apikey == '${apiKeys.secretKey}' and 'Bearer ${apiKeys.serviceRoleKey}') ` + + `or (headers.apikey == '${apiKeys.publishableKey}' and 'Bearer ${apiKeys.anonKey}') ` + + `or headers.apikey)` + ); +} + +/** + * Go's `QueryToken` (`start.go:515-521`): the same mapping as + * {@link legacyBuildKongBearerToken}, applied to the `apikey` query parameter + * instead of a header, and without the `Bearer sb_...` passthrough branch + * (there is no equivalent "already a query-string bearer" case). + */ +export function legacyBuildKongQueryToken(apiKeys: LegacyKongApiKeys): string { + return ( + `$((query_params.apikey == '${apiKeys.secretKey}' and '${apiKeys.serviceRoleKey}') ` + + `or (query_params.apikey == '${apiKeys.publishableKey}' and '${apiKeys.anonKey}') ` + + `or query_params.apikey)` + ); +} + +/** + * Go's `envOrDefault("KONG_NGINX_WORKER_PROCESSES", "1")` (`start.go:583`, + * helper at `start.go:1464-1471`): the operator's own shell value wins when + * set (e.g. `KONG_NGINX_WORKER_PROCESSES=auto` for one worker per CPU core), + * otherwise Go's default of a single worker to minimize local-stack memory + * usage (Ref: supabase/cli#1271). By the time Go's `os.LookupEnv` runs here, + * `Config.Load` has already merged any project dotenv file into the real + * process env (`loadEnvIfExists`/`godotenv.Load`) — `projectEnvValues` is + * this port's equivalent merged (dotenv + ambient shell, ambient-wins) view, + * so a `KONG_NGINX_WORKER_PROCESSES` set only in a project dotenv file (not + * the ambient shell) is honored too, matching Storage's identical + * `VECTOR_*`-env handling (`storage.service.ts`). Kept separate from + * {@link legacyBuildKongContainerSpec} (which stays a pure function of + * already-resolved values, matching every other `start`-service builder) so + * this one ambient-env read is independently testable and the builder itself + * never touches `process.env`. + */ +export function legacyResolveKongNginxWorkerProcesses( + projectEnvValues: Readonly> | undefined = undefined, +): string { + return legacyEnvOrDefault("KONG_NGINX_WORKER_PROCESSES", "1", projectEnvValues); +} + +export interface LegacyKongEmailTemplateMount { + /** + * Go's `mountEmailTemplates(id, contentPath string)` `id` parameter + * (`start.go:527-542`): the raw `config.auth.email.template` key for a + * template mount, or `_notification` for an enabled + * `config.auth.email.notification` entry (`start.go:551-557`) — the caller + * is responsible for that suffixing and for filtering notifications down to + * `enabled` ones; this module only reproduces the per-mount path derivation. + */ + readonly id: string; + /** `tmpl.ContentPath` — empty means "not configured", matching Go's `len(contentPath) == 0` early return (no bind emitted). */ + readonly contentPath: string; +} + +/** + * Go's `mountEmailTemplates` closure (`start.go:527-542`): resolves + * `contentPath` to an absolute HOST path via `filepath.Abs` (relative to the + * process's own working directory — NOT `/supabase`, unlike + * `legacyResolveEmailTemplateContentPath`'s existence-check base in + * `legacy-config-validate.ts`, a genuinely different Go call site with a + * different base), joins it onto the fixed in-container email-template + * directory as `` (`path.Join`, POSIX — the container is + * always Linux regardless of the host OS, hence `nodePath.posix.join`, not the + * platform-dependent `nodePath.join`), and formats the `rw` bind. Returns + * `undefined` for an empty `contentPath`, matching Go's no-op early return + * (no bind appended). + */ +export function legacyBuildKongEmailTemplateBind( + mount: LegacyKongEmailTemplateMount, + workdir: string, +): string | undefined { + if (mount.contentPath.length === 0) return undefined; + const hostPath = nodePath.isAbsolute(mount.contentPath) + ? mount.contentPath + : nodePath.resolve(workdir, mount.contentPath); + const dockerPath = nodePath.posix.join( + LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR, + `${mount.id}${nodePath.extname(hostPath)}`, + ); + return `${hostPath}:${dockerPath}:rw`; +} + +const LEGACY_KONG_ENTRYPOINT_HEAD = + "cat <<'EOF' > /home/kong/custom_nginx.template && \\\n" + + "./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n"; + +/** + * Reproduces the surviving (non-secret) half of Go's exact string + * concatenation for the Kong entrypoint (`start.go:588-601`): only the + * `custom_nginx.template` heredoc and the final `docker-entrypoint.sh` exec + * line — `LEGACY_KONG_ENTRYPOINT_HEAD + nginxTemplate + "\nEOF\n"`. The other + * three heredocs Go's version chains ahead of this one (`kong.yml`, the TLS + * cert, the TLS key) no longer travel through this script at all — see this + * module's header comment for why (`secretFiles`, CWE-214/522). + */ +export function legacyBuildKongEntrypointScript(nginxTemplate: string): string { + return LEGACY_KONG_ENTRYPOINT_HEAD + nginxTemplate + "\nEOF\n"; +} + +export interface LegacyKongContainerSpecInput { + /** `config.api.kong_image`, already resolved/pulled by the caller. */ + readonly image: string; + /** `legacyServiceContainerName("kong", projectId)` — Go's `utils.KongId`. */ + readonly containerName: string; + /** Go's `utils.NetId` — the shared Docker network every `start` container joins. */ + readonly networkId: string; + /** `config.hostname`, post-override — the `kongConfig.ApiHost` template field (currently unreferenced by `kong.yml`'s body, but still required to match Go's struct exactly). */ + readonly apiHost: string; + /** + * `config.api.port`, post-`SUPABASE_API_PORT`-override — used for the + * `kongConfig.ApiPort` template field, `KONG_PORT_MAPS`, and (alongside + * {@link apiTlsEnabled}) the published host port. + */ + readonly apiPort: number; + /** `config.api.tls.enabled`, post-override — selects the published container port (`8443` vs `8000`, `start.go:560-563`). */ + readonly apiTlsEnabled: boolean; + /** + * `Config.Api.Tls.CertContent` as a string. NOT empty-by-default: Go's + * `NewConfig` seeds this with the embedded default cert + * (`pkg/config/config.go:452-455`, `LEGACY_KONG_LOCAL_TLS_CERT`) and only + * `Validate` overwrites it from `api.tls.cert_path` when TLS is enabled AND + * both `cert_path`/`key_path` are configured — the caller must pass the + * embedded default here otherwise, matching Kong's own unconditional write + * of this field to `/home/kong/localhost.crt` (`start.go:585-601`). + */ + readonly tlsCertContent: string; + /** `Config.Api.Tls.KeyContent` as a string — see {@link tlsCertContent} for the same embedded-default requirement. */ + readonly tlsKeyContent: string; + /** The four already-generated API keys `BearerToken`/`QueryToken` are built from — see {@link legacyBuildKongBearerToken}/{@link legacyBuildKongQueryToken}. */ + readonly apiKeys: LegacyKongApiKeys; + /** Go's `utils.GotrueId` — GoTrue's own container name. */ + readonly gotrueId: string; + /** Go's `utils.RestId` — PostgREST's own container name. */ + readonly restId: string; + /** + * Go's `Config.Realtime.TenantId` (`start.go:492`) — NOT Realtime's + * container name/id. Realtime is reachable under this same value because + * it is ALSO Realtime's own network alias (`utils.RealtimeAliases = + * ["realtime", Config.Realtime.TenantId]`), so `kong.yml`'s `url: + * http://{{ .RealtimeId }}:4000/...` resolves via that alias, not via + * `legacyServiceContainerName("realtime", projectId)`. + */ + readonly realtimeTenantId: string; + /** Go's `utils.StorageId` — Storage's own container name. */ + readonly storageId: string; + /** Go's `utils.StudioId` — Studio's own container name. */ + readonly studioId: string; + /** Go's `utils.PgmetaId` — pg-meta's own container name. */ + readonly pgmetaId: string; + /** Go's `utils.EdgeRuntimeId` — Edge Runtime's own container name. */ + readonly edgeRuntimeId: string; + /** Go's `utils.LogflareId` — Logflare's own container name. */ + readonly logflareId: string; + /** Go's `utils.PoolerId` — Supavisor's own container name. */ + readonly poolerId: string; + /** + * `envOrDefault("KONG_NGINX_WORKER_PROCESSES", "1")` — already resolved by + * the caller via {@link legacyResolveKongNginxWorkerProcesses}, keeping + * this builder a pure function of its `input`. + */ + readonly nginxWorkerProcesses: string; + /** + * Go's `workdir` (`os.Getwd()` in `run()`, `start.go:308`) — used to + * resolve any relative {@link emailTemplateMounts} `contentPath` to an + * absolute host path (`filepath.Abs`, `start.go:531-538`). + */ + readonly workdir: string; + /** + * Every `config.auth.email.template.*`/enabled `config.auth.email.notification.*` + * entry the caller has already gathered (`start.go:544-558`) — see + * {@link LegacyKongEmailTemplateMount}'s doc comment for the notification + * `id` suffixing/filtering the caller owns. Defaults to `[]` (no email + * template mounts). + */ + readonly emailTemplateMounts?: ReadonlyArray; +} + +/** + * Assembles Kong's {@link LegacyStartContainerSpec}. Pure — no Effect or + * ambient I/O — matching every other `start`-service builder in this + * directory. + */ +export function legacyBuildKongContainerSpec( + input: LegacyKongContainerSpecInput, +): LegacyStartContainerSpec { + const kongYml = legacyRenderStartKongYml({ + gotrueId: input.gotrueId, + restId: input.restId, + realtimeId: input.realtimeTenantId, + storageId: input.storageId, + studioId: input.studioId, + pgmetaId: input.pgmetaId, + edgeRuntimeId: input.edgeRuntimeId, + logflareId: input.logflareId, + poolerId: input.poolerId, + apiHost: input.apiHost, + apiPort: input.apiPort, + bearerToken: legacyBuildKongBearerToken(input.apiKeys), + queryToken: legacyBuildKongQueryToken(input.apiKeys), + }); + + const binds = (input.emailTemplateMounts ?? []) + .map((mount) => legacyBuildKongEmailTemplateBind(mount, input.workdir)) + .filter((bind): bind is string => bind !== undefined); + + const dockerPort = input.apiTlsEnabled ? 8443 : 8000; + + return { + image: input.image, + containerName: input.containerName, + env: { + KONG_DATABASE: "off", + KONG_DECLARATIVE_CONFIG: "/home/kong/kong.yml", + // Ref: https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: "LAST,A,CNAME", + KONG_PLUGINS: "request-transformer,cors", + KONG_PORT_MAPS: `${input.apiPort}:8000`, + // Ref: https://github.com/Kong/kong/issues/3974#issuecomment-482105126 + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: "160k", + KONG_NGINX_PROXY_PROXY_BUFFERS: "64 160k", + KONG_NGINX_WORKER_PROCESSES: input.nginxWorkerProcesses, + KONG_SSL_CERT: "/home/kong/localhost.crt", + KONG_SSL_CERT_KEY: "/home/kong/localhost.key", + }, + entrypoint: "sh", + cmd: ["-c", legacyBuildKongEntrypointScript(LEGACY_START_CUSTOM_NGINX_TEMPLATE)], + secretFiles: [ + { containerPath: "/home/kong/kong.yml", content: kongYml }, + { containerPath: "/home/kong/localhost.crt", content: input.tlsCertContent }, + { containerPath: "/home/kong/localhost.key", content: input.tlsKeyContent }, + ], + binds, + ports: [{ hostPort: String(input.apiPort), containerPort: String(dockerPort) }], + exposedPorts: [ + { containerPort: "8000" }, + { containerPort: "8443" }, + { containerPort: String(LEGACY_KONG_NGINX_TEMPLATE_SERVER_PORT) }, + ], + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: LEGACY_KONG_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts new file mode 100644 index 0000000000..47bc0c9830 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildKongBearerToken, + legacyBuildKongContainerSpec, + legacyBuildKongEmailTemplateBind, + legacyBuildKongEntrypointScript, + legacyBuildKongQueryToken, + legacyResolveKongNginxWorkerProcesses, + type LegacyKongApiKeys, + type LegacyKongContainerSpecInput, +} from "./kong.service.ts"; + +const apiKeys: LegacyKongApiKeys = { + secretKey: "sb_secret_abc", + serviceRoleKey: "service-role-jwt", + publishableKey: "sb_publishable_abc", + anonKey: "anon-jwt", +}; + +describe("legacyBuildKongBearerToken", () => { + test("builds the exact lua request-transformer expression (start.go:501-514)", () => { + expect(legacyBuildKongBearerToken(apiKeys)).toBe( + "$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) " + + "or (headers.apikey == 'sb_secret_abc' and 'Bearer service-role-jwt') " + + "or (headers.apikey == 'sb_publishable_abc' and 'Bearer anon-jwt') " + + "or headers.apikey)", + ); + }); +}); + +describe("legacyBuildKongQueryToken", () => { + test("builds the exact lua query-param expression (start.go:515-521)", () => { + expect(legacyBuildKongQueryToken(apiKeys)).toBe( + "$((query_params.apikey == 'sb_secret_abc' and 'service-role-jwt') " + + "or (query_params.apikey == 'sb_publishable_abc' and 'anon-jwt') " + + "or query_params.apikey)", + ); + }); +}); + +describe("legacyResolveKongNginxWorkerProcesses", () => { + test('defaults to "1" when unset (start.go:1466-1471)', () => { + expect(legacyResolveKongNginxWorkerProcesses(undefined)).toBe("1"); + }); + + test("uses a project dotenv-only value, matching Go's post-Load os.LookupEnv", () => { + expect(legacyResolveKongNginxWorkerProcesses({ KONG_NGINX_WORKER_PROCESSES: "auto" })).toBe( + "auto", + ); + }); +}); + +describe("legacyBuildKongEmailTemplateBind", () => { + test("returns undefined for an empty contentPath (start.go:528-530)", () => { + expect( + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "" }, "/work"), + ).toBeUndefined(); + }); + + test("resolves a relative contentPath against workdir (start.go:531-538)", () => { + expect( + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "invite.html" }, "/work"), + ).toBe("/work/invite.html:/home/kong/templates/email/invite.html:rw"); + }); + + test("leaves an absolute contentPath untouched", () => { + expect( + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "/abs/invite.html" }, "/work"), + ).toBe("/abs/invite.html:/home/kong/templates/email/invite.html:rw"); + }); + + test("drops the extension when hostPath has none", () => { + expect( + legacyBuildKongEmailTemplateBind( + { id: "invite_notification", contentPath: "invite" }, + "/work", + ), + ).toBe("/work/invite:/home/kong/templates/email/invite_notification:rw"); + }); +}); + +describe("legacyBuildKongEntrypointScript", () => { + test("writes only the custom_nginx.template heredoc, then execs docker-entrypoint.sh (start.go:588-601, minus the secretFiles-carried heredocs)", () => { + const script = legacyBuildKongEntrypointScript("NGINX_TEMPLATE"); + expect(script).toBe( + "cat <<'EOF' > /home/kong/custom_nginx.template && \\\n" + + "./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n" + + "NGINX_TEMPLATE\nEOF\n", + ); + }); + + test("no longer references kong.yml or the TLS cert/key paths at all", () => { + const script = legacyBuildKongEntrypointScript("NGINX_TEMPLATE"); + expect(script).not.toContain("kong.yml"); + expect(script).not.toContain("localhost.crt"); + expect(script).not.toContain("localhost.key"); + }); +}); + +const base: LegacyKongContainerSpecInput = { + image: "supabase/kong:3.0.0", + containerName: "supabase_kong_proj", + networkId: "supabase_network_proj", + apiHost: "localhost", + apiPort: 54321, + apiTlsEnabled: false, + tlsCertContent: "", + tlsKeyContent: "", + apiKeys, + gotrueId: "supabase_auth_proj", + restId: "supabase_rest_proj", + realtimeTenantId: "realtime-dev", + storageId: "supabase_storage_proj", + studioId: "supabase_studio_proj", + pgmetaId: "supabase_pg_meta_proj", + edgeRuntimeId: "supabase_edge_runtime_proj", + logflareId: "supabase_analytics_proj", + poolerId: "supabase_pooler_proj", + nginxWorkerProcesses: "1", + workdir: "/work", +}; + +describe("legacyBuildKongContainerSpec", () => { + test("builds identity, entrypoint, restart policy, and network aliases (start.go:564-627)", () => { + const spec = legacyBuildKongContainerSpec(base); + expect(spec.image).toBe("supabase/kong:3.0.0"); + expect(spec.containerName).toBe("supabase_kong_proj"); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd?.[0]).toBe("-c"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.networkAliases).toEqual(["kong", "api.supabase.internal"]); + expect(spec.labels).toEqual({}); + expect(spec.healthcheck).toBeUndefined(); + }); + + test("emits the fixed KONG_* env vars, including the resolved worker-process count (start.go:568-587)", () => { + const spec = legacyBuildKongContainerSpec(base); + expect(spec.env).toEqual({ + KONG_DATABASE: "off", + KONG_DECLARATIVE_CONFIG: "/home/kong/kong.yml", + KONG_DNS_ORDER: "LAST,A,CNAME", + KONG_PLUGINS: "request-transformer,cors", + KONG_PORT_MAPS: "54321:8000", + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: "160k", + KONG_NGINX_PROXY_PROXY_BUFFERS: "64 160k", + KONG_NGINX_WORKER_PROCESSES: "1", + KONG_SSL_CERT: "/home/kong/localhost.crt", + KONG_SSL_CERT_KEY: "/home/kong/localhost.key", + }); + }); + + test("publishes 8000 to the host and exposes 8000/8443/8088 when TLS is disabled (start.go:560-563,602-612)", () => { + const spec = legacyBuildKongContainerSpec({ ...base, apiTlsEnabled: false }); + expect(spec.ports).toEqual([{ hostPort: "54321", containerPort: "8000" }]); + expect(spec.exposedPorts).toEqual([ + { containerPort: "8000" }, + { containerPort: "8443" }, + { containerPort: "8088" }, + ]); + }); + + test("publishes 8443 to the host when TLS is enabled, exposed ports unchanged", () => { + const spec = legacyBuildKongContainerSpec({ ...base, apiTlsEnabled: true }); + expect(spec.ports).toEqual([{ hostPort: "54321", containerPort: "8443" }]); + expect(spec.exposedPorts).toEqual([ + { containerPort: "8000" }, + { containerPort: "8443" }, + { containerPort: "8088" }, + ]); + }); + + test("renders kong.yml using Config.Realtime.TenantId, not Realtime's container name (start.go:492)", () => { + const spec = legacyBuildKongContainerSpec(base); + const kongYml = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/kong.yml", + )?.content; + expect(kongYml).toContain("http://realtime-dev:4000/socket"); + expect(kongYml).not.toContain("supabase_realtime_proj"); + }); + + test("embeds the bearer/query token lua expressions into the rendered kong.yml", () => { + const spec = legacyBuildKongContainerSpec(base); + const kongYml = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/kong.yml", + )?.content; + expect(kongYml).toContain(legacyBuildKongBearerToken(apiKeys)); + expect(kongYml).toContain(legacyBuildKongQueryToken(apiKeys)); + }); + + test("has no email template binds by default", () => { + const spec = legacyBuildKongContainerSpec(base); + expect(spec.binds).toEqual([]); + }); + + test("mounts every resolved email template bind (start.go:544-558)", () => { + const spec = legacyBuildKongContainerSpec({ + ...base, + emailTemplateMounts: [ + { id: "invite", contentPath: "invite.html" }, + { id: "confirmation_notification", contentPath: "" }, + { id: "recovery_notification", contentPath: "/abs/recovery.html" }, + ], + }); + expect(spec.binds).toEqual([ + "/work/invite.html:/home/kong/templates/email/invite.html:rw", + "/abs/recovery.html:/home/kong/templates/email/recovery_notification.html:rw", + ]); + }); + + test("carries kong.yml and the TLS cert/key as secretFiles at the exact paths KONG_DECLARATIVE_CONFIG/KONG_SSL_CERT/KONG_SSL_CERT_KEY reference, never in cmd (CWE-214/522)", () => { + const spec = legacyBuildKongContainerSpec({ + ...base, + tlsCertContent: "-----BEGIN CERTIFICATE-----", + tlsKeyContent: "-----BEGIN PRIVATE KEY-----", + }); + expect(spec.secretFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ containerPath: "/home/kong/localhost.crt" }), + expect.objectContaining({ containerPath: "/home/kong/localhost.key" }), + ]), + ); + const cert = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/localhost.crt", + )?.content; + const key = spec.secretFiles?.find( + (f) => f.containerPath === "/home/kong/localhost.key", + )?.content; + expect(cert).toBe("-----BEGIN CERTIFICATE-----"); + expect(key).toBe("-----BEGIN PRIVATE KEY-----"); + + const script = String(spec.cmd?.[1]); + expect(script).not.toContain("-----BEGIN CERTIFICATE-----"); + expect(script).not.toContain("-----BEGIN PRIVATE KEY-----"); + expect(script).not.toContain("kong.yml"); + }); + + test("still carries (empty-content) TLS cert/key secretFiles entries when TLS is unconfigured — an unconditional bind, matching Go's always-written empty files", () => { + const spec = legacyBuildKongContainerSpec(base); + const cert = spec.secretFiles?.find((f) => f.containerPath === "/home/kong/localhost.crt"); + const key = spec.secretFiles?.find((f) => f.containerPath === "/home/kong/localhost.key"); + expect(cert?.content).toBe(""); + expect(key?.content).toBe(""); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts new file mode 100644 index 0000000000..3b61d6a9d7 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -0,0 +1,159 @@ +/** + * Port of Go's "Start Logflare" block (`apps/cli-go/internal/start/start.go: + * 313-394`), gated on `config.analytics.enabled` — the gate itself is + * `start.handler.ts`'s job (a later task), not this module's; this file only + * builds the `docker create` spec. + * + * Two things make this the fullest worked example among the three real + * services ported in this file group: + * + * - A custom `Entrypoint`/`Cmd` pair that writes and runs its own `run.sh`, + * because the image's own entrypoint conflicts with the healthcheck due to + * a 15-second sleep + * (https://github.com/Logflare/logflare/blob/staging/run.sh#L35). + * - A `config.analytics.backend` branch (`postgres` vs `bigquery`, + * `start.go:333-348`) that appends different env vars (and, for BigQuery + * only, a bind mount for the GCP service-account JSON). + */ + +import { join } from "node:path"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** `utils.LogflareAliases[0]` (`apps/cli-go/internal/utils/config.go:47`) — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ +const LEGACY_LOGFLARE_CONTAINER_SUFFIX = "analytics"; + +/** + * `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — the DB + * user Logflare's own Ecto connection authenticates as. Distinct from + * {@link LegacyLogflareContainerSpecInput.dbUser} (`dbConfig.User = "postgres"` + * in Go), which is used only for the Postgres-backend `POSTGRES_BACKEND_URL` + * below (`start.go:344-347`) — Go really does use two different DB users for + * two different env vars in this one block. + */ +const LEGACY_LOGFLARE_DB_USERNAME = "supabase_admin"; + +/** + * `Config.Analytics.ApiKey`'s only possible value + * (`apps/cli-go/pkg/config/config.go:307,529`). The field is `toml:"-"` — + * excluded from `config.toml` unmarshalling entirely — so this can never + * actually vary; hardcoding it here (rather than threading it through as an + * input) mirrors that Go compile-time constant exactly. + */ +const LEGACY_LOGFLARE_API_KEY = "api-key"; + +/** + * Go's Logflare entrypoint script (`start.go:358-362`): the image's own + * entrypoint conflicts with the container healthcheck due to a 15-second + * sleep, so Go writes its own `run.sh` and runs that instead. Transcribed + * byte-for-byte, including the trailing newline after `EOF` (Go's raw string + * literal ends with a newline before the closing backtick). + */ +const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = + "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate\n./logflare start --sname logflare\nEOF\n"; + +export interface LegacyLogflareContainerSpecInput { + /** + * `container.Config.Image` — the already-resolved `config.analytics.image`. + * Not part of the decoded `@supabase/config` schema (Go's own + * `Analytics.Image` field is `toml:"-"`); resolution is the caller's + * responsibility. + */ + readonly image: string; + /** Go's `Config.ProjectId`, used to derive `utils.LogflareId` via {@link legacyServiceContainerName}. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`'s target — resolved once per `start` run, not per-container. */ + readonly networkId: string; + /** `config.analytics.port` — published as `4000/tcp`. */ + readonly port: number; + /** `config.analytics.backend` (`start.go:333`). */ + readonly backend: "postgres" | "bigquery"; + /** `config.analytics.gcp_project_id` — only read when {@link backend} is `"bigquery"` (`start.go:340`). */ + readonly gcpProjectId: string; + /** `config.analytics.gcp_project_number` — only read when {@link backend} is `"bigquery"` (`start.go:341`). */ + readonly gcpProjectNumber: string; + /** + * `config.analytics.gcp_jwt_path` — only read when {@link backend} is + * `"bigquery"` (`start.go:335-336`). Joined onto {@link workdir} + * UNCONDITIONALLY, exactly like Go's own `filepath.Join(workdir, + * GcpJwtPath)` — an empty string still produces a (degenerate) bind mount of + * `workdir` itself, matching Go's behavior when the field is unset. + */ + readonly gcpJwtPath: string; + /** `os.Getwd()` at the call site (`start.go:308-311`) — the process working directory, used to resolve {@link gcpJwtPath} to a host path. */ + readonly workdir: string; + /** `dbConfig.Host` (`utils.DbId` on the default path — see `start.go:66-72`). */ + readonly dbHost: string; + /** `dbConfig.Port` (`5432` on the default path). */ + readonly dbPort: number; + /** + * `dbConfig.User` (`"postgres"` on the default path) — used only for the + * Postgres-backend `POSTGRES_BACKEND_URL` env var, NOT for + * `DB_USERNAME` (see {@link LEGACY_LOGFLARE_DB_USERNAME}'s doc comment). + */ + readonly dbUser: string; + /** `dbConfig.Password` (`Config.Db.Password`). */ + readonly dbPassword: string; +} + +/** Builds the `docker create` spec for the Logflare/analytics container (`start.go:313-394`). */ +export function legacyBuildLogflareContainerSpec( + input: LegacyLogflareContainerSpecInput, +): LegacyStartContainerSpec { + const env: Record = { + DB_DATABASE: "_supabase", + DB_HOSTNAME: input.dbHost, + DB_PORT: String(input.dbPort), + DB_SCHEMA: "_analytics", + DB_USERNAME: LEGACY_LOGFLARE_DB_USERNAME, + DB_PASSWORD: input.dbPassword, + LOGFLARE_MIN_CLUSTER_SIZE: "1", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_PRIVATE_ACCESS_TOKEN: LEGACY_LOGFLARE_API_KEY, + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: "127.0.0.1", + // The literal env VALUE includes the single quotes (start.go:328) — Go sets + // this directly on container.Config.Env, never through a shell, so the + // quotes are not stripped anywhere. + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", + RELEASE_COOKIE: "cookie", + }; + + const binds: Array = []; + + if (input.backend === "bigquery") { + const hostJwtPath = join(input.workdir, input.gcpJwtPath); + binds.push(`${hostJwtPath}:/opt/app/rel/logflare/bin/gcloud.json`); + env.GOOGLE_DATASET_ID_APPEND = "_prod"; + env.GOOGLE_PROJECT_ID = input.gcpProjectId; + env.GOOGLE_PROJECT_NUMBER = input.gcpProjectNumber; + } else { + env.POSTGRES_BACKEND_URL = `postgresql://${input.dbUser}:${input.dbPassword}@${input.dbHost}:${input.dbPort}/_supabase`; + env.POSTGRES_BACKEND_SCHEMA = "_analytics"; + } + + return { + image: input.image, + containerName: legacyServiceContainerName(LEGACY_LOGFLARE_CONTAINER_SUFFIX, input.projectId), + hostname: "127.0.0.1", + env, + entrypoint: "sh", + cmd: ["-c", LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT], + binds, + exposedPorts: [{ containerPort: "4000" }], + ports: [{ hostPort: String(input.port), containerPort: "4000" }], + healthcheck: { + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: [LEGACY_LOGFLARE_CONTAINER_SUFFIX], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts new file mode 100644 index 0000000000..881f243f7d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -0,0 +1,111 @@ +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + legacyBuildLogflareContainerSpec, + type LegacyLogflareContainerSpecInput, +} from "./logflare.service.ts"; + +const base: LegacyLogflareContainerSpecInput = { + image: "supabase/logflare:1.0.0", + projectId: "proj", + networkId: "supabase_network_proj", + port: 54327, + backend: "postgres", + gcpProjectId: "", + gcpProjectNumber: "", + gcpJwtPath: "", + workdir: "/workdir", + dbHost: "supabase_db_proj", + dbPort: 5432, + dbUser: "postgres", + dbPassword: "secret", +}; + +describe("legacyBuildLogflareContainerSpec", () => { + test("builds the shared shape: identity, hostname, entrypoint/cmd, ports, healthcheck, aliases (start.go:350-394)", () => { + const spec = legacyBuildLogflareContainerSpec(base); + expect(spec.image).toBe("supabase/logflare:1.0.0"); + expect(spec.containerName).toBe("supabase_analytics_proj"); + expect(spec.hostname).toBe("127.0.0.1"); + expect(spec.entrypoint).toBe("sh"); + expect(spec.cmd).toEqual([ + "-c", + "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate\n./logflare start --sname logflare\nEOF\n", + ]); + expect(spec.exposedPorts).toEqual([{ containerPort: "4000" }]); + expect(spec.ports).toEqual([{ hostPort: "54327", containerPort: "4000" }]); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.networkAliases).toEqual(["analytics"]); + expect(spec.networkId).toBe("supabase_network_proj"); + }); + + test("emits the common DB_*/LOGFLARE_* env vars regardless of backend (start.go:315-330)", () => { + const spec = legacyBuildLogflareContainerSpec(base); + expect(spec.env).toMatchObject({ + DB_DATABASE: "_supabase", + DB_HOSTNAME: "supabase_db_proj", + DB_PORT: "5432", + DB_SCHEMA: "_analytics", + DB_USERNAME: "supabase_admin", + DB_PASSWORD: "secret", + LOGFLARE_MIN_CLUSTER_SIZE: "1", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: "127.0.0.1", + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", + RELEASE_COOKIE: "cookie", + }); + }); + + test("postgres backend: sets POSTGRES_BACKEND_URL/SCHEMA, no GCP env or bind, no bind mounts (start.go:343-347)", () => { + const spec = legacyBuildLogflareContainerSpec({ ...base, backend: "postgres" }); + expect(spec.env.POSTGRES_BACKEND_URL).toBe( + "postgresql://postgres:secret@supabase_db_proj:5432/_supabase", + ); + expect(spec.env.POSTGRES_BACKEND_SCHEMA).toBe("_analytics"); + expect(spec.env.GOOGLE_PROJECT_ID).toBeUndefined(); + expect(spec.env.GOOGLE_PROJECT_NUMBER).toBeUndefined(); + expect(spec.env.GOOGLE_DATASET_ID_APPEND).toBeUndefined(); + expect(spec.binds).toEqual([]); + }); + + test("bigquery backend: sets GOOGLE_* env and binds the host JWT path, no postgres env (start.go:334-342)", () => { + const spec = legacyBuildLogflareContainerSpec({ + ...base, + backend: "bigquery", + gcpProjectId: "my-project", + gcpProjectNumber: "123456", + gcpJwtPath: "gcloud.json", + workdir: "/workdir", + }); + expect(spec.env.GOOGLE_DATASET_ID_APPEND).toBe("_prod"); + expect(spec.env.GOOGLE_PROJECT_ID).toBe("my-project"); + expect(spec.env.GOOGLE_PROJECT_NUMBER).toBe("123456"); + expect(spec.env.POSTGRES_BACKEND_URL).toBeUndefined(); + expect(spec.env.POSTGRES_BACKEND_SCHEMA).toBeUndefined(); + expect(spec.binds).toEqual([ + `${join("/workdir", "gcloud.json")}:/opt/app/rel/logflare/bin/gcloud.json`, + ]); + }); + + test("bigquery backend still binds workdir itself when gcpJwtPath is empty, matching Go's unconditional filepath.Join", () => { + const spec = legacyBuildLogflareContainerSpec({ + ...base, + backend: "bigquery", + gcpJwtPath: "", + workdir: "/workdir", + }); + expect(spec.binds).toEqual([`${join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/mailpit.service.ts b/apps/cli/src/legacy/commands/start/services/mailpit.service.ts new file mode 100644 index 0000000000..9ddb6faa77 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/mailpit.service.ts @@ -0,0 +1,93 @@ +/** + * Port of Go's "Start Mailpit" block (`apps/cli-go/internal/start/start.go: + * 853-901`), gated on `config.inbucket.enabled` (`utils.Config.Inbucket.Enabled` + * in Go) — the gate itself is `start.handler.ts`'s job (a later task), not + * this module's; this file only builds the `docker create` spec. + * + * The simplest of the container-bring-up services in this port: no hostname + * override, no entrypoint/cmd override, no binds, and exactly one + * unconditional env var. The only real branching is which of the two optional + * ports (SMTP, POP3) get published alongside the always-on web UI port. + */ + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** + * `utils.InbucketAliases[0]` (`apps/cli-go/internal/utils/config.go:39`) — also + * this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG` + * (`legacy-service-catalog.ts`). Mailpit keeps the Go-internal "inbucket" name + * (the product it replaced) for the container/alias/id, even though the + * user-facing service and config section are "Mailpit"/`config.inbucket`. + */ +const LEGACY_MAILPIT_CONTAINER_SUFFIX = "inbucket"; + +export interface LegacyMailpitContainerSpecInput { + /** + * `container.Config.Image` — the already-resolved `config.inbucket.image`. + * Not part of the decoded `@supabase/config` schema (Go's own + * `Inbucket.Image` field is `toml:"-"`); resolution is the caller's + * responsibility, same as every other service in this port. + */ + readonly image: string; + /** Go's `Config.ProjectId`, used to derive `utils.InbucketId` via {@link legacyServiceContainerName}. */ + readonly projectId: string; + /** + * `container.HostConfig.NetworkMode`'s target — resolved once per `start` + * run, not per-container (see `LegacyStartContainerSpec.networkId`'s doc + * comment in `docker-create-args.ts`). + */ + readonly networkId: string; + /** `config.inbucket.port` — always published as `8025/tcp` (`start.go:855-857`). */ + readonly port: number; + /** + * `config.inbucket.smtp_port` — published as `1025/tcp` only when set and + * non-zero, matching Go's `SmtpPort != 0` guard (`start.go:858-862`). + * `@supabase/config` has no default for this key, so an absent value + * decodes to `undefined` — the same "unset" case as Go's zero `uint16`. + */ + readonly smtpPort?: number; + /** + * `config.inbucket.pop3_port` — published as `1110/tcp` only when set and + * non-zero, matching Go's `Pop3Port != 0` guard (`start.go:863-867`). + */ + readonly pop3Port?: number; +} + +/** Builds the `docker create` spec for the Mailpit/Inbucket container (`start.go:853-901`). */ +export function legacyBuildMailpitContainerSpec( + input: LegacyMailpitContainerSpecInput, +): LegacyStartContainerSpec { + const ports: Array<{ hostPort: string; containerPort: string }> = [ + { hostPort: String(input.port), containerPort: "8025" }, + ]; + if (input.smtpPort !== undefined && input.smtpPort !== 0) { + ports.push({ hostPort: String(input.smtpPort), containerPort: "1025" }); + } + if (input.pop3Port !== undefined && input.pop3Port !== 0) { + ports.push({ hostPort: String(input.pop3Port), containerPort: "1110" }); + } + + return { + image: input.image, + containerName: legacyServiceContainerName(LEGACY_MAILPIT_CONTAINER_SUFFIX, input.projectId), + env: { + // Disable reverse DNS lookups in Mailpit to avoid slow/delayed DNS resolution (start.go:873-874). + MP_SMTP_DISABLE_RDNS: "true", + }, + binds: [], + ports, + healthcheck: { + test: ["CMD", "/mailpit", "readyz"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + // StartPeriod taken from upstream Dockerfile (start.go:881-882). + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: [LEGACY_MAILPIT_CONTAINER_SUFFIX], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/mailpit.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/mailpit.service.unit.test.ts new file mode 100644 index 0000000000..af8d104479 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/mailpit.service.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest"; + +import { legacyBuildMailpitContainerSpec } from "./mailpit.service.ts"; + +describe("legacyBuildMailpitContainerSpec", () => { + test("builds the minimal spec with only the always-on web UI port bound (start.go:853-901)", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "supabase/mailpit:v1", + projectId: "proj", + networkId: "supabase_network_proj", + port: 54324, + }); + + expect(spec).toEqual({ + image: "supabase/mailpit:v1", + containerName: "supabase_inbucket_proj", + env: { MP_SMTP_DISABLE_RDNS: "true" }, + binds: [], + ports: [{ hostPort: "54324", containerPort: "8025" }], + healthcheck: { + test: ["CMD", "/mailpit", "readyz"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + startPeriodSeconds: 10, + }, + restartPolicy: "unless-stopped", + networkId: "supabase_network_proj", + networkAliases: ["inbucket"], + labels: {}, + }); + }); + + test("adds the SMTP port binding only when smtpPort is set and non-zero (start.go:858-862)", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + smtpPort: 54325, + }); + expect(spec.ports).toEqual([ + { hostPort: "54324", containerPort: "8025" }, + { hostPort: "54325", containerPort: "1025" }, + ]); + }); + + test("omits the SMTP port binding when smtpPort is explicitly 0, matching Go's zero-value guard", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + smtpPort: 0, + }); + expect(spec.ports).toEqual([{ hostPort: "54324", containerPort: "8025" }]); + }); + + test("adds the POP3 port binding only when pop3Port is set and non-zero (start.go:863-867)", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + pop3Port: 1110, + }); + expect(spec.ports).toEqual([ + { hostPort: "54324", containerPort: "8025" }, + { hostPort: "1110", containerPort: "1110" }, + ]); + }); + + test("adds both optional ports together, in Go's declared order", () => { + const spec = legacyBuildMailpitContainerSpec({ + image: "img", + projectId: "proj", + networkId: "net", + port: 54324, + smtpPort: 54325, + pop3Port: 1110, + }); + expect(spec.ports).toEqual([ + { hostPort: "54324", containerPort: "8025" }, + { hostPort: "54325", containerPort: "1025" }, + { hostPort: "1110", containerPort: "1110" }, + ]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts new file mode 100644 index 0000000000..9cd9fd0988 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts @@ -0,0 +1,82 @@ +/** + * pg-meta container spec builder — port of Go's "Start pg-meta" block + * (`apps/cli-go/internal/start/start.go:1110-1146`). Gated in Go by + * `config.studio.enabled` (pg-meta has no `enabled` flag of its own — it only + * exists to back Studio's schema browser) and + * `!isContainerExcluded(config.studio.pgmeta_image, excluded)` — see + * `legacy-service-catalog.ts`'s `pgMeta` entry (`excludeKey: "postgres-meta"`, + * gated on `studio.enabled`). Gating and image resolution/pre-pull are the + * caller's job (a future `start.handler.ts`); this module only assembles the + * container spec once the caller has already decided to start it, matching + * `docker-create-args.ts`'s "image already resolved/pulled" contract. + * + * No separately-tested pure env function the way Studio has + * (`buildLegacyStudioEnv`): pg-meta's env is 6 straight `KEY=value` + * assignments with no derived formatting or conditional logic, so + * {@link buildLegacyPgMetaContainerSpec} is the only exported entry point. + */ + +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** Go's hardcoded pg-meta listen port (`start.go:1117`, `PG_META_PORT=8080`) — never configurable. */ +const PG_META_PORT = 8080; + +/** Go's `utils.PgmetaAliases` (`apps/cli-go/internal/utils/config.go:44`) — a fixed, non-configurable constant. */ +const PG_META_NETWORK_ALIASES = ["pg_meta"]; + +export interface LegacyPgMetaContainerInput { + /** `config.studio.pgmeta_image`, already resolved/pulled by the caller. */ + readonly image: string; + /** `legacyServiceContainerName("pg_meta", projectId)` — Go's `utils.PgmetaId`. */ + readonly containerName: string; + /** + * Go's `dbConfig.Host` (`utils.DbId`, `internal/start/start.go:66`) — the + * local Postgres container's own hostname on the shared Docker network. + */ + readonly dbHost: string; + /** Go's `dbConfig.Port` (hardcoded `5432`, `start.go:67`). */ + readonly dbPort: number; + /** Go's `dbConfig.User` (hardcoded `"postgres"`, `start.go:68`). */ + readonly dbUser: string; + /** Go's `dbConfig.Password` (`config.db.password`, `legacyResolveLocalConfigValues`'s resolved value). */ + readonly dbPassword: string; + /** Go's `dbConfig.Database` (hardcoded `"postgres"`, `start.go:70`). */ + readonly dbName: string; + /** Go's `utils.NetId` — the shared Docker network every `start` container joins. */ + readonly networkId: string; +} + +/** + * Assembles pg-meta's {@link LegacyStartContainerSpec}. Pure — no Effect or + * I/O — matching `docker-create-args.ts`'s own builder shape. + */ +export function buildLegacyPgMetaContainerSpec( + input: LegacyPgMetaContainerInput, +): LegacyStartContainerSpec { + return { + image: input.image, + containerName: input.containerName, + env: { + PG_META_PORT: String(PG_META_PORT), + PG_META_DB_HOST: input.dbHost, + PG_META_DB_NAME: input.dbName, + PG_META_DB_USER: input.dbUser, + PG_META_DB_PORT: String(input.dbPort), + PG_META_DB_PASSWORD: input.dbPassword, + }, + binds: [], + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${PG_META_PORT}/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: PG_META_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts new file mode 100644 index 0000000000..5f4f119877 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.unit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; + +import { buildLegacyPgMetaContainerSpec } from "./pg-meta.service.ts"; + +describe("buildLegacyPgMetaContainerSpec", () => { + test("assembles the full container spec from resolved inputs", () => { + const spec = buildLegacyPgMetaContainerSpec({ + image: "supabase/postgres-meta:v0.96.6", + containerName: "supabase_pg_meta_proj", + dbHost: "supabase_db_proj", + dbPort: 5432, + dbUser: "postgres", + dbPassword: "postgres", + dbName: "postgres", + networkId: "supabase_network_proj", + }); + + expect(spec).toEqual({ + image: "supabase/postgres-meta:v0.96.6", + containerName: "supabase_pg_meta_proj", + env: { + PG_META_PORT: "8080", + PG_META_DB_HOST: "supabase_db_proj", + PG_META_DB_NAME: "postgres", + PG_META_DB_USER: "postgres", + PG_META_DB_PORT: "5432", + PG_META_DB_PASSWORD: "postgres", + }, + binds: [], + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:8080/health').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: "supabase_network_proj", + networkAliases: ["pg_meta"], + labels: {}, + }); + }); + + test("reflects a non-default db host/port/password in env, while the healthcheck stays on the fixed container port", () => { + const spec = buildLegacyPgMetaContainerSpec({ + image: "supabase/postgres-meta:v0.96.6", + containerName: "supabase_pg_meta_proj", + dbHost: "custom-db-host", + dbPort: 6543, + dbUser: "postgres", + dbPassword: "hunter2", + dbName: "postgres", + networkId: "supabase_network_proj", + }); + + expect(spec.env["PG_META_DB_HOST"]).toBe("custom-db-host"); + expect(spec.env["PG_META_DB_PORT"]).toBe("6543"); + expect(spec.env["PG_META_DB_PASSWORD"]).toBe("hunter2"); + expect(spec.healthcheck?.test[1]).toContain("127.0.0.1:8080"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.ts b/apps/cli/src/legacy/commands/start/services/postgres.service.ts new file mode 100644 index 0000000000..18f80136ef --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgres.service.ts @@ -0,0 +1,331 @@ +/** + * Port of Go's `NewContainerConfig`/`NewHostConfig` + * (`apps/cli-go/internal/db/start/start.go:63-131`): builds the + * {@link LegacyStartContainerSpec} for `supabase start`'s Postgres container. + * + * Deliberately out of scope, per the approved start-port plan: + * - `StartDatabase`'s `fromBackup` restore branch (`start.go:143-164`, + * `templates/restore.sh`) — `supabase start` always calls `StartDatabase` + * with an empty `fromBackup` (`apps/cli-go/internal/start/start.go:295`), + * so that whole branch is dead code on this path. + * - `SetupLocalDatabase` (initial schema bootstrap, `start.go:184-187`) — an + * explicit follow-up, not container construction. + * - Actually creating/starting the container and waiting for it to become + * healthy — that's {@link legacyStartContainer} (`../lib/container-lifecycle.ts`) + * and {@link legacyWaitForHealthyServices} (`../lib/health-check.ts`), wired + * up by a later `start.handler.ts` task. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { localDbContainerId } from "../../../shared/legacy-docker-ids.ts"; +import { encodeToml } from "../../../shared/legacy-go-output.encoders.ts"; +import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../../../shared/legacy-local-config-values.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { LEGACY_START_DB_SCHEMA_SQL } from "../templates/db-schema.sql.ts"; +import { LEGACY_START_DB_SUPABASE_SQL } from "../templates/db-supabase.sql.ts"; +import { LEGACY_START_DB_WEBHOOK_SQL } from "../templates/db-webhook.sql.ts"; + +/** Go's `Db.Password` default (`pkg/config/config.go:459`). `db.password` has no + * config.toml field (`toml:"-"`, `pkg/config/db.go:88`), so this is the only value + * this port can ever observe — matches `DEFAULT_DB_PASSWORD` in + * `legacy-local-config-values.ts`, not imported from there since that constant + * isn't exported and status/stop's resolver is otherwise unrelated to this module. */ +const LEGACY_POSTGRES_PASSWORD = "postgres"; + +/** + * The exact in-container path Go's PG >= 15 entrypoint heredocs the pgsodium + * root key to (`start.go:96`) — now a `secretFiles` bind-mount target instead + * (see {@link legacyBuildPostgresStartContainerSpec}), not a heredoc. + */ +const LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; + +/** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ +const LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; +const LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; +const LEGACY_POSTGRES_HEALTHCHECK_RETRIES = 3; + +/** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ +const LEGACY_POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; + +/** Go's version-compare threshold (`apps/cli-go/internal/db/start/start.go:79`). */ +const LEGACY_POSTGRES_INITDB_VERSION_THRESHOLD = "15.8.1.005"; + +const LEGACY_POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; + +export interface LegacyPostgresStartServiceInput { + /** Decoded `[db]` section — every field this builder needs (`port`, `major_version`, `settings`) lives here. */ + readonly db: ProjectConfig["db"]; + /** Decoded `[experimental]` section — only the OrioleDB/S3 fields are read. */ + readonly experimental: ProjectConfig["experimental"]; + /** Already-resolved (default-or-configured, decrypted) `auth.jwt_secret` — same shape `legacyResolveLocalConfigValues` produces. */ + readonly jwtSecret: string; + /** `config.auth.jwt_expiry`. */ + readonly jwtExpiry: number; + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s doc comment. */ + readonly projectId: string; + /** `utils.NetId` — the local stack's docker network id. */ + readonly networkId: string; + /** `utils.Config.Db.Image`, already resolved/pulled (see `../lib/image-prepull.ts`) — the container's own image. */ + readonly image: string; + /** + * `utils.Config.Db.Image` BEFORE registry resolution — Go's + * `POSTGRES_INITDB_ARGS` version-tag comparison (`start.go:79`) always runs + * against this un-rewritten value; the registry candidate only ever + * overwrites the container's `Image` field, afterward, inside `DockerStart` + * (`docker.go:365,371`). Passed separately from {@link image} because a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` override containing a port (e.g. + * `localhost:5000`) would otherwise inject an extra colon that breaks + * {@link legacyPostgresImageVersionTag}'s first-colon tag split. + */ + readonly configImage: string; + /** Already-resolved `db.root_key` value. Defaults to {@link LEGACY_POSTGRES_DEFAULT_ROOT_KEY} when omitted — see that constant's doc comment for why. */ + readonly rootKey?: string; +} + +/** + * Port of Go's `(a *settings) ToPostgresConfig()` + * (`apps/cli-go/pkg/config/db.go:181-190`): serializes `db.settings` as TOML — + * only the fields actually set, matching Go's nil-pointer fields never being + * written — replaces every `"` with `'`, and prepends the fixed header + * comment. + * + * Reuses the shared {@link encodeToml} (`legacy-go-output.encoders.ts`, backed + * by `smol-toml`) for the actual line rendering: `smol-toml`'s `stringifyTable` + * already skips `undefined`/`null` values exactly like Go's TOML encoder + * (`github.com/BurntSushi/toml`'s `eStruct`) skips nil pointers — verified + * against that library's source, which omits a nil field unconditionally, with + * no `omitempty` tag required — and its integer/string/boolean formatting + * already matches Go's (unquoted numbers/bools, double-quoted strings, single- + * quoted here afterward). The one divergence: `smol-toml`'s `stringify` always + * appends a trailing `\n`, even for an empty object (`stringify({})` → + * `"\n"`), whereas Go's `ToTomlBytes` of an all-nil-pointer struct returns the + * empty string (`TestSettingsToPostgresConfig`'s "Empty settings should + * result in empty string" case) — so the empty-settings case is special-cased + * below instead of delegated to `encodeToml`. + * + * `settings` itself is typed optional (`ProjectConfig["db"]["settings"]` + * includes `undefined`) because `db.ts` wraps the whole `[db.settings]` table + * in `Schema.optionalKey` — in practice the schema's own `withDecodingDefaultKey` + * always fills in `{}` when the section is absent, but this stays defensive + * against the static type either way, matching Go's `settings` being a plain + * (never-nil) struct value. + */ +export function legacyPostgresSettingsToPostgresConfig( + settings: ProjectConfig["db"]["settings"], +): string { + const defined = Object.fromEntries( + Object.entries(settings ?? {}).filter(([, value]) => value !== undefined), + ); + if (Object.keys(defined).length === 0) { + return LEGACY_POSTGRES_CONFIG_HEADER; + } + const toml = encodeToml(defined).replaceAll('"', "'"); + return `${LEGACY_POSTGRES_CONFIG_HEADER}${toml}`; +} + +/** + * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) + * — NOT a real semver comparator. A dotted version with more than 3 components + * truncates to its first 3 as the primary comparison key, and compares the + * remaining components — joined and left-trimmed of leading `0` characters — + * as a secondary tie-break (Go: `semver.Compare("v"+pA, "v"+pB)`). Both real + * inputs at this module's one call site (a Postgres image tag and the + * `"15.8.1.005"` threshold) always have exactly 4 numeric components, so this + * only reproduces Go's `golang.org/x/mod/semver` invalid-version rule (an + * invalid version string sorts before a valid one; two invalid strings + * compare equal — exercised by Go's own `TestVersionCompare` `"oriole-17"` + * cases) for the narrower set of shapes real inputs can take; full semver + * pre-release/build-metadata syntax is out of scope. + */ +export function legacyPostgresVersionCompare(a: string, b: string): number { + const [aHead, aTail] = legacySplitVersionHeadTail(a); + const [bHead, bTail] = legacySplitVersionHeadTail(b); + const headCompare = legacyCompareVersionStrings(aHead, bHead); + if (headCompare !== 0) return headCompare; + return legacyCompareVersionStrings(aTail, bTail); +} + +function legacySplitVersionHeadTail(version: string): readonly [string, string] { + const parts = version.split("."); + if (parts.length <= 3) return [version, ""]; + return [parts.slice(0, 3).join("."), parts.slice(3).join(".").replace(/^0+/, "")]; +} + +function legacyIsValidDottedVersion(version: string): boolean { + return version.length > 0 && version.split(".").every((part) => /^\d+$/.test(part)); +} + +function legacyCompareVersionStrings(a: string, b: string): number { + const aValid = legacyIsValidDottedVersion(a); + const bValid = legacyIsValidDottedVersion(b); + if (!aValid || !bValid) { + return aValid === bValid ? 0 : aValid ? 1 : -1; + } + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + const length = Math.max(aParts.length, bParts.length); + for (let index = 0; index < length; index += 1) { + const diff = (aParts[index] ?? 0) - (bParts[index] ?? 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + return 0; +} + +/** + * Go's `i := strings.IndexByte(utils.Config.Db.Image, ':'); ...Image[i+1:]` + * (`apps/cli-go/internal/db/start/start.go:79`) — the FIRST colon splits the + * image name from its tag. Go's own `Db.Image` is never registry-prefixed at + * this point in Go's pipeline (registry resolution only overwrites the + * container's `Image` field afterward, inside `DockerStart` — + * `docker.go:365,371`), so the first colon is always the name/tag separator. + * The caller MUST pass the pre-registry-rewrite image (see + * {@link LegacyPostgresStartServiceInput.configImage}) — a resolved image can + * carry a registry host prefix with its own colon (e.g. a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY=localhost:5000` override), which would + * otherwise be misparsed as the tag. When no colon is present at all, Go's + * slice expression degrades to the whole string (`Image[0:]`) — reproduced + * here the same way. + */ +export function legacyPostgresImageVersionTag(image: string): string { + const colonIndex = image.indexOf(":"); + return colonIndex === -1 ? image : image.slice(colonIndex + 1); +} + +/** + * Go's OrioleDB / version-compare `Env` branch (`start.go:70-81`) — an + * `else if`, so at most one of the two ever fires. + */ +function legacyPostgresExtraEnv( + experimental: ProjectConfig["experimental"], + image: string, +): Readonly> { + if (experimental.orioledb_version !== undefined && experimental.orioledb_version.length > 0) { + return { + POSTGRES_INITDB_ARGS: "--lc-collate=C --lc-ctype=C", + S3_ENABLED: "true", + S3_HOST: experimental.s3_host ?? "", + S3_REGION: experimental.s3_region ?? "", + S3_ACCESS_KEY: experimental.s3_access_key ?? "", + S3_SECRET_KEY: experimental.s3_secret_key ?? "", + }; + } + const tag = legacyPostgresImageVersionTag(image); + if (legacyPostgresVersionCompare(tag, LEGACY_POSTGRES_INITDB_VERSION_THRESHOLD) < 0) { + return { POSTGRES_INITDB_ARGS: "--lc-collate=C.UTF-8" }; + } + return {}; +} + +/** + * PG >= 15 entrypoint (`config.db.major_version > 14`, Go's default branch, + * `start.go:91-104`): writes `/etc/postgresql.schema.sql` (schema.sql + + * webhook.sql + _supabase.sql, concatenated in that exact order), appends + * `postgresConfig` to `postgresql.conf`, then execs `docker-entrypoint.sh`. + * Go also heredocs `/etc/postgresql-custom/pgsodium_root.key` directly into + * this same script (`start.go:96`) — safe for Go, which calls + * `Docker.ContainerCreate` over the Engine API directly rather than shelling + * out. THIS PORT SHELLS OUT to a real `docker create`, so it deliberately + * diverges here: the pgsodium root key travels via + * {@link LegacyStartContainerSpec.secretFiles} instead (a HOST temp file, + * mode `0600`, bind-mounted read-only at that exact path — see + * {@link legacyBuildPostgresStartContainerSpec}), so it never appears in this + * process's own `docker create` argv (CWE-214/522). + * + * Otherwise byte-for-byte derived from Go's raw-string concatenation + * (including the trailing space after `/etc/postgresql` — Go's + * `NewContainerConfig(args ...string)` joins its variadic `args` there, + * always empty for `supabase start`, so the space survives as-is); built via + * explicit `"...\n" +` concatenation rather than a multi-line template + * literal so that trailing space stays a visible, lint/format-proof string + * character instead of invisible end-of-line whitespace. + */ +function legacyPostgresEntrypointScriptPg15(postgresConfig: string): string { + return ( + "\n" + + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SCHEMA_SQL}\n` + + `${LEGACY_START_DB_WEBHOOK_SQL}\n` + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${postgresConfig}\n` + + "EOF" + ); +} + +/** + * PG <= 14 entrypoint (`start.go:106-113`): a shorter script — no + * `schema.sql`/`webhook.sql` (PG >= 15 only) and no pgsodium root key file — + * writes `/docker-entrypoint-initdb.d/supabase_schema.sql` (_supabase.sql + * only), appends `postgresConfig` to `postgresql.conf`, then execs + * `docker-entrypoint.sh`. See {@link legacyPostgresEntrypointScriptPg15}'s doc + * comment for why this is explicit concatenation rather than a template + * literal. + */ +function legacyPostgresEntrypointScriptPg14(postgresConfig: string): string { + return ( + "\n" + + "cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${postgresConfig}\n` + + "EOF" + ); +} + +/** + * Builds the {@link LegacyStartContainerSpec} for `supabase start`'s Postgres + * container — see this module's header for what's deliberately out of scope. + */ +export function legacyBuildPostgresStartContainerSpec( + input: LegacyPostgresStartServiceInput, +): LegacyStartContainerSpec { + const containerName = localDbContainerId(input.projectId); + const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; + const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); + const isPg14OrEarlier = input.db.major_version <= 14; + + const env: Record = { + POSTGRES_PASSWORD: LEGACY_POSTGRES_PASSWORD, + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: input.jwtSecret, + JWT_EXP: String(input.jwtExpiry), + ...legacyPostgresExtraEnv(input.experimental, input.configImage), + }; + + const script = isPg14OrEarlier + ? legacyPostgresEntrypointScriptPg14(postgresConfig) + : legacyPostgresEntrypointScriptPg15(postgresConfig); + + return { + image: input.image, + containerName, + env, + entrypoint: "sh", + cmd: ["-c", script], + binds: [`${containerName}:/var/lib/postgresql/data`], + ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], + healthcheck: { + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, + timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, + retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: LEGACY_POSTGRES_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts new file mode 100644 index 0000000000..b651045860 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts @@ -0,0 +1,315 @@ +import type { ProjectConfig } from "@supabase/config"; +import { describe, expect, test } from "vitest"; + +import { LEGACY_START_DB_SCHEMA_SQL } from "../templates/db-schema.sql.ts"; +import { LEGACY_START_DB_SUPABASE_SQL } from "../templates/db-supabase.sql.ts"; +import { LEGACY_START_DB_WEBHOOK_SQL } from "../templates/db-webhook.sql.ts"; +import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../../../shared/legacy-local-config-values.ts"; +import { + legacyBuildPostgresStartContainerSpec, + legacyPostgresImageVersionTag, + legacyPostgresSettingsToPostgresConfig, + legacyPostgresVersionCompare, + type LegacyPostgresStartServiceInput, +} from "./postgres.service.ts"; + +const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; + +function baseDb(overrides: Partial = {}): ProjectConfig["db"] { + return { + port: 54322, + shadow_port: 54320, + health_timeout: "2m", + major_version: 17, + pooler: { + enabled: false, + port: 54329, + pool_mode: "transaction", + default_pool_size: 20, + max_client_conn: 100, + }, + migrations: { enabled: true, schema_paths: [] }, + seed: { enabled: true, sql_paths: [] }, + settings: {}, + network_restrictions: { enabled: false, allowed_cidrs: [], allowed_cidrs_v6: [] }, + ...overrides, + }; +} + +function baseExperimental( + overrides: Partial = {}, +): ProjectConfig["experimental"] { + return { + webhooks: { enabled: false }, + pgdelta: { enabled: false }, + inspect: { rules: [] }, + ...overrides, + }; +} + +function baseInput( + overrides: Partial = {}, +): LegacyPostgresStartServiceInput { + return { + db: baseDb(), + experimental: baseExperimental(), + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + projectId: "myproj", + networkId: "supabase_network_myproj", + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + ...overrides, + }; +} + +describe("legacyBuildPostgresStartContainerSpec", () => { + test("PG >= 15: concatenates schema.sql + webhook.sql + _supabase.sql into the schema heredoc, appends the postgres config, and carries the pgsodium root key as a secretFile instead of a heredoc", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 17 }) }), + ); + + expect(spec.entrypoint).toBe("sh"); + const script = spec.cmd?.[1]; + expect(script).toBe( + "\n" + + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SCHEMA_SQL}\n` + + `${LEGACY_START_DB_WEBHOOK_SQL}\n` + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${POSTGRES_CONFIG_HEADER}\n` + + "EOF", + ); + expect(script).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + expect(script).not.toContain("pgsodium_root.key"); + expect(spec.tmpfs).toBeUndefined(); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/postgresql-custom/pgsodium_root.key", + content: LEGACY_POSTGRES_DEFAULT_ROOT_KEY, + }, + ]); + }); + + test("PG <= 14: writes only _supabase.sql (no schema.sql/webhook.sql, no pgsodium root key) and sets the initdb tmpfs mount", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 14 }) }), + ); + + const script = spec.cmd?.[1]; + expect(script).toBe( + "\n" + + "cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${POSTGRES_CONFIG_HEADER}\n` + + "EOF", + ); + expect(script).not.toContain("postgresql.schema.sql"); + expect(script).not.toContain("pgsodium_root.key"); + expect(spec.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + expect(spec.secretFiles).toBeUndefined(); + }); + + test("uses a rootKey override instead of the Go default when provided", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 17 }), rootKey: "custom-root-key" }), + ); + expect(spec.secretFiles).toEqual([ + { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "custom-root-key" }, + ]); + expect(spec.cmd?.[1]).not.toContain("custom-root-key"); + expect(spec.cmd?.[1]).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + }); + + test("base env carries password, host, jwt secret, and jwt expiry", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.env).toMatchObject({ + POSTGRES_PASSWORD: "postgres", + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + JWT_EXP: "3600", + }); + }); + + test("OrioleDB branch: adds POSTGRES_INITDB_ARGS + S3 env vars and skips the version-compare branch", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + experimental: baseExperimental({ + orioledb_version: "17.4.1.030", + s3_host: "s3.example.com", + s3_region: "us-east-1", + s3_access_key: "access-key", + s3_secret_key: "secret-key", + }), + // Old enough to trigger the version-compare branch too, if it weren't skipped. + image: "supabase/postgres:orioledb-15.1.0.55", + }), + ); + expect(spec.env).toEqual({ + POSTGRES_PASSWORD: "postgres", + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: "super-secret-jwt-token-with-at-least-32-characters-long", + JWT_EXP: "3600", + POSTGRES_INITDB_ARGS: "--lc-collate=C --lc-ctype=C", + S3_ENABLED: "true", + S3_HOST: "s3.example.com", + S3_REGION: "us-east-1", + S3_ACCESS_KEY: "access-key", + S3_SECRET_KEY: "secret-key", + }); + }); + + test("OrioleDB branch defaults unset S3 fields to empty strings, matching Go's zero-value string fields", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ experimental: baseExperimental({ orioledb_version: "17.4.1.030" }) }), + ); + expect(spec.env).toMatchObject({ + S3_HOST: "", + S3_REGION: "", + S3_ACCESS_KEY: "", + S3_SECRET_KEY: "", + }); + }); + + test("version-compare branch: adds POSTGRES_INITDB_ARGS=--lc-collate=C.UTF-8 when the image tag is below the threshold", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "public.ecr.aws/supabase/postgres:15.1.0.117", + configImage: "supabase/postgres:15.1.0.117", + }), + ); + expect(spec.env.POSTGRES_INITDB_ARGS).toBe("--lc-collate=C.UTF-8"); + }); + + test("version-compare branch is skipped when the image tag is at or above the threshold", () => { + const atThreshold = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "public.ecr.aws/supabase/postgres:15.8.1.005", + configImage: "supabase/postgres:15.8.1.005", + }), + ); + expect(atThreshold.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + + const aboveThreshold = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + }), + ); + expect(aboveThreshold.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + }); + + test("version-compare branch reads the pre-registry-rewrite configImage, not the registry-resolved image (a port-bearing registry override must not break the tag parse)", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ + image: "localhost:5000/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + }), + ); + expect(spec.env.POSTGRES_INITDB_ARGS).toBeUndefined(); + }); + + test("healthcheck matches Go's pg_isready probe", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.healthcheck).toEqual({ + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("port binding maps the configured db.port to container port 5432", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput({ db: baseDb({ port: 12345 }) })); + expect(spec.ports).toEqual([{ hostPort: "12345", containerPort: "5432" }]); + }); + + test("binds a named volume keyed by the container's own (sanitized) name", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput({ projectId: "my project!" })); + expect(spec.containerName).toBe("supabase_db_my_project_"); + expect(spec.binds).toEqual(["supabase_db_my_project_:/var/lib/postgresql/data"]); + }); + + test("network id, aliases, restart policy, and image pass through unchanged", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ networkId: "supabase_network_myproj", image: "some/resolved-image:17.4.1.030" }), + ); + expect(spec.networkId).toBe("supabase_network_myproj"); + expect(spec.networkAliases).toEqual(["db", "db.supabase.internal"]); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.image).toBe("some/resolved-image:17.4.1.030"); + expect(spec.labels).toEqual({}); + }); +}); + +describe("legacyPostgresSettingsToPostgresConfig", () => { + test("only set values appear, in the configured field's TOML form", () => { + const config = legacyPostgresSettingsToPostgresConfig({ + max_connections: 100, + max_locks_per_transaction: 64, + shared_buffers: "128MB", + work_mem: "4MB", + }); + expect(config).toContain("max_connections = 100"); + expect(config).toContain("max_locks_per_transaction = 64"); + expect(config).toContain("shared_buffers = '128MB'"); + expect(config).toContain("work_mem = '4MB'"); + expect(config).not.toContain("effective_cache_size"); + expect(config).not.toContain("maintenance_work_mem"); + expect(config).not.toContain("max_parallel_workers"); + }); + + test("session_replication_role is single-quoted like every other string field", () => { + const config = legacyPostgresSettingsToPostgresConfig({ session_replication_role: "origin" }); + expect(config).toContain("session_replication_role = 'origin'"); + }); + + test("empty settings produce just the header, with no trailing content", () => { + const config = legacyPostgresSettingsToPostgresConfig({}); + expect(config).toBe(POSTGRES_CONFIG_HEADER); + expect(config).not.toContain("="); + }); + + test("a boolean field is emitted unquoted", () => { + const config = legacyPostgresSettingsToPostgresConfig({ track_commit_timestamp: true }); + expect(config).toContain("track_commit_timestamp = true"); + }); +}); + +describe("legacyPostgresVersionCompare", () => { + test.each([ + ["15.1.0.55", "15.1.0.55", 0], + ["15.8.1.085", "15.1.0.55", 1], + ["15.1.0.55", "15.8.1.085", -1], + ["17.4.1.005", "17.4.1.005", 0], + ["17.4.1.030", "17.4.1.005", 1], + ["17.4.1.005", "17.4.1.030", -1], + ["15.8.1", "15.8.1", 0], + ["17", "15.8", 1], + ["14", "15.8", -1], + ["oriole-17", "oriole-17", 0], + ["17", "oriole-17", 1], + ["oriole-17", "17", -1], + ] as const)("VersionCompare(%s, %s) === %d", (a, b, expected) => { + expect(legacyPostgresVersionCompare(a, b)).toBe(expected); + }); +}); + +describe("legacyPostgresImageVersionTag", () => { + test("extracts the tag after the last colon, ignoring any registry host prefix", () => { + expect(legacyPostgresImageVersionTag("public.ecr.aws/supabase/postgres:15.1.0.117")).toBe( + "15.1.0.117", + ); + expect(legacyPostgresImageVersionTag("supabase/postgres:17.4.1.030")).toBe("17.4.1.030"); + }); + + test("degrades to the whole string when there is no colon at all, matching Go's Image[i+1:] with i=-1", () => { + expect(legacyPostgresImageVersionTag("supabase/postgres")).toBe("supabase/postgres"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/postgrest.service.ts b/apps/cli/src/legacy/commands/start/services/postgrest.service.ts new file mode 100644 index 0000000000..7e933a1be3 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgrest.service.ts @@ -0,0 +1,114 @@ +/** + * Port of Go's "Start PostgREST" block + * (`apps/cli-go/internal/start/start.go:960-992`). + * + * Enabled gate: `config.api.enabled` (`utils.Config.Api.Enabled`, + * `start.go:961`). Gating (this field, plus `!isContainerExcluded`) is the + * caller's responsibility — see `start.services.ts`'s `postgrest` catalog + * entry (`enabledGate: "api.enabled"`). + * + * No `Healthcheck` field at all: Go's own `container.Config` literal for this + * service (`start.go:964-976`) sets only `Image`/`Env`, with the comment + * `// PostgREST does not expose a shell for health check` in place of a + * `Healthcheck:` entry — confirmed by reading the struct literal itself, not + * just the comment. PostgREST readiness is instead checked at runtime via an + * HTTP HEAD through the local Kong gateway + * (`legacyCheckHttpReady`/`LEGACY_POSTGREST_READY_PATH`, `../lib/health-check.ts`, + * itself porting `status.go:159-229`'s "PostgREST does not support native + * health checks" branch) — this builder correctly omits `healthcheck` so + * `buildLegacyStartContainerCreateArgs` never emits a `--health-*` flag for + * this container, matching `docker-create-args.ts`'s own documented + * PostgREST exception. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { + legacyStartInternalDbPassword, + legacyStartInternalDbUrl, +} from "../lib/internal-db-connection.ts"; + +export interface LegacyPostgrestEnvInput { + /** `config.api.schemas` — joined with `,` into `PGRST_DB_SCHEMAS`. */ + readonly schemas: ProjectConfig["api"]["schemas"]; + /** `config.api.extra_search_path` — joined with `,` into `PGRST_DB_EXTRA_SEARCH_PATH`. */ + readonly extraSearchPath: ProjectConfig["api"]["extra_search_path"]; + /** `config.api.max_rows`. */ + readonly maxRows: ProjectConfig["api"]["max_rows"]; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + readonly dbPassword: string; + /** + * `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds + * `PGRST_JWT_SECRET` directly (Go's `fmt.Sprintf("PGRST_JWT_SECRET=%s", + * jwks)`, `start.go:972`; despite the env var's name, PostgREST is fed the + * JWKS document, not the raw `auth.jwt_secret`). + */ + readonly jwks: string; +} + +/** + * Pure env-var builder, split out from + * {@link legacyBuildPostgrestContainerSpec} so the full Go `Env` literal + * (`start.go:966-973`) is unit-testable without constructing a whole + * container spec. + */ +export function legacyBuildPostgrestEnv(input: LegacyPostgrestEnvInput): Record { + return { + PGRST_DB_URI: legacyStartInternalDbUrl("authenticator", input.dbHost, input.dbPassword), + PGRST_DB_SCHEMAS: input.schemas.join(","), + PGRST_DB_EXTRA_SEARCH_PATH: input.extraSearchPath.join(","), + PGRST_DB_MAX_ROWS: String(input.maxRows), + PGRST_DB_ANON_ROLE: "anon", + PGRST_JWT_SECRET: input.jwks, + PGRST_ADMIN_SERVER_PORT: "3001", + }; +} + +export interface LegacyPostgrestContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Api.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; + readonly schemas: ProjectConfig["api"]["schemas"]; + readonly extraSearchPath: ProjectConfig["api"]["extra_search_path"]; + readonly maxRows: ProjectConfig["api"]["max_rows"]; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password. */ + readonly dbUrl: string; + readonly jwks: string; +} + +/** + * Builds the `docker create` spec for the PostgREST container + * (`start.go:960-992`). No `ports`/`exposedPorts` either — Go's + * `container.Config` literal for this service declares neither. + */ +export function legacyBuildPostgrestContainerSpec( + input: LegacyPostgrestContainerSpecInput, +): LegacyStartContainerSpec { + const env = legacyBuildPostgrestEnv({ + schemas: input.schemas, + extraSearchPath: input.extraSearchPath, + maxRows: input.maxRows, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + jwks: input.jwks, + }); + + return { + image: input.image, + containerName: legacyServiceContainerName("rest", input.projectId), + env, + binds: [], + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.RestAliases = []string{"rest"}` (`utils/config.go:41`). + networkAliases: ["rest"], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/postgrest.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/postgrest.service.unit.test.ts new file mode 100644 index 0000000000..957b845820 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/postgrest.service.unit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildPostgrestContainerSpec, + legacyBuildPostgrestEnv, + type LegacyPostgrestContainerSpecInput, +} from "./postgrest.service.ts"; + +describe("legacyBuildPostgrestEnv", () => { + const base = { + schemas: ["public", "graphql_public"], + extraSearchPath: ["public", "extensions"], + maxRows: 1000, + dbHost: "supabase_db_proj", + dbPassword: "postgres", + jwks: '{"keys":[]}', + }; + + test("wires PGRST_DB_URI as the authenticator role against the internal DB address", () => { + const env = legacyBuildPostgrestEnv(base); + expect(env["PGRST_DB_URI"]).toBe( + "postgresql://authenticator:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("joins schemas and extra_search_path with commas", () => { + const env = legacyBuildPostgrestEnv(base); + expect(env["PGRST_DB_SCHEMAS"]).toBe("public,graphql_public"); + expect(env["PGRST_DB_EXTRA_SEARCH_PATH"]).toBe("public,extensions"); + }); + + test("feeds the resolved JWKS string into PGRST_JWT_SECRET, not the raw jwt secret", () => { + const env = legacyBuildPostgrestEnv({ ...base, jwks: '{"keys":["fake"]}' }); + expect(env["PGRST_JWT_SECRET"]).toBe('{"keys":["fake"]}'); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildPostgrestEnv(base); + expect(env).toMatchObject({ + PGRST_DB_MAX_ROWS: "1000", + PGRST_DB_ANON_ROLE: "anon", + PGRST_ADMIN_SERVER_PORT: "3001", + }); + }); +}); + +describe("legacyBuildPostgrestContainerSpec", () => { + const input: LegacyPostgrestContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/postgrest:v12", + schemas: ["public"], + extraSearchPath: ["public"], + maxRows: 1000, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + jwks: '{"keys":[]}', + }; + + test("derives the container name and internal DB host from projectId", () => { + const spec = legacyBuildPostgrestContainerSpec(input); + expect(spec.containerName).toBe("supabase_rest_proj"); + expect(spec.env["PGRST_DB_URI"]).toBe( + "postgresql://authenticator:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("has no healthcheck, no ports, and no exposedPorts — matching Go's PostgREST container.Config", () => { + const spec = legacyBuildPostgrestContainerSpec(input); + expect(spec.healthcheck).toBeUndefined(); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toBeUndefined(); + }); + + test("network alias is 'rest'", () => { + const spec = legacyBuildPostgrestContainerSpec(input); + expect(spec.networkAliases).toEqual(["rest"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("reuses (does not recompute) the resolved db password from a non-default dbUrl", () => { + const spec = legacyBuildPostgrestContainerSpec({ + ...input, + dbUrl: "postgresql://postgres:another-secret@127.0.0.1:54322/postgres", + }); + expect(spec.env["PGRST_DB_URI"]).toBe( + "postgresql://authenticator:another-secret@supabase_db_proj:5432/postgres", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.ts new file mode 100644 index 0000000000..1f0571da34 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -0,0 +1,158 @@ +/** + * Port of Go's "Start Realtime" block + * (`apps/cli-go/internal/start/start.go:903-958`). + * + * Enabled gate: `config.realtime.enabled` (`utils.Config.Realtime.Enabled`, + * `start.go:904`) — independent of `config.api.enabled` (PostgREST's own + * gate, `api.go:961`); the two are never conflated in Go. Gating (this field, + * plus `!isContainerExcluded`) is the caller's responsibility — see + * `start.services.ts`'s `realtime` catalog entry (`enabledGate: + * "realtime.enabled"`) — this module only builds the container spec once + * called. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { + legacyStartInternalDbPassword, + LEGACY_START_INTERNAL_DB_NAME, + LEGACY_START_INTERNAL_DB_PORT, +} from "../lib/internal-db-connection.ts"; + +/** + * Go's `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — + * Realtime's fixed `DB_USER` (`start.go:913`). Unrelated to the per-service + * role each OTHER container's own DB connection string uses (PostgREST's + * `authenticator`, Storage's `supabase_storage_admin`), so it is not hoisted + * alongside `legacyStartInternalDbUrl`. + */ +const LEGACY_REALTIME_DB_USER = "supabase_admin"; + +/** + * Go's `realtime.TenantId` default (`pkg/config/config.go:481`) — `toml:"-"` + * (`config.go:254`), so never configurable via `config.toml` or a + * `SUPABASE_*` override; always this literal. Exported: `kong.service.ts`'s + * `kong.yml` template needs this exact same value for its `RealtimeId` field + * (Go's `Config.Realtime.TenantId`, `start.go:492` — NOT Realtime's own + * container name/id, see that module's `realtimeTenantId` doc comment). + */ +export const LEGACY_REALTIME_TENANT_ID = "realtime-dev"; + +/** Go's `realtime.EncryptionKey` default (`pkg/config/config.go:482`) — `toml:"-"`, never configurable. */ +const LEGACY_REALTIME_ENCRYPTION_KEY = "supabaserealtime"; + +/** Go's `realtime.SecretKeyBase` default (`pkg/config/config.go:483`) — `toml:"-"`, never configurable. */ +const LEGACY_REALTIME_SECRET_KEY_BASE = + "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG"; + +export interface LegacyRealtimeEnvInput { + /** `config.realtime.ip_version` — feeds `utils.ToRealtimeEnv` (`utils/config.go:209-214`). */ + readonly ipVersion: ProjectConfig["realtime"]["ip_version"]; + /** `config.realtime.max_header_length`. */ + readonly maxHeaderLength: ProjectConfig["realtime"]["max_header_length"]; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See {@link legacyStartInternalDbPassword}. */ + readonly dbPassword: string; + /** `LegacyLocalConfigValues.jwtSecret` — feeds both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds `API_JWT_JWKS`. */ + readonly jwks: string; +} + +/** + * Pure env-var builder, split out from {@link legacyBuildRealtimeContainerSpec} + * so the full Go `Env` literal (`start.go:909-929`) is unit-testable without + * constructing a whole container spec. + */ +export function legacyBuildRealtimeEnv(input: LegacyRealtimeEnvInput): Record { + return { + PORT: "4000", + DB_HOST: input.dbHost, + DB_PORT: String(LEGACY_START_INTERNAL_DB_PORT), + DB_USER: LEGACY_REALTIME_DB_USER, + DB_PASSWORD: input.dbPassword, + DB_NAME: LEGACY_START_INTERNAL_DB_NAME, + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: LEGACY_REALTIME_ENCRYPTION_KEY, + API_JWT_SECRET: input.jwtSecret, + API_JWT_JWKS: input.jwks, + METRICS_JWT_SECRET: input.jwtSecret, + APP_NAME: "realtime", + SECRET_KEY_BASE: LEGACY_REALTIME_SECRET_KEY_BASE, + ERL_AFLAGS: input.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", + // Two literal single-quote characters, exactly like Go's `"DNS_NODES=''"` (`start.go:924`). + DNS_NODES: "''", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: String(input.maxHeaderLength), + }; +} + +export interface LegacyRealtimeContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Realtime.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; + readonly ipVersion: ProjectConfig["realtime"]["ip_version"]; + readonly maxHeaderLength: ProjectConfig["realtime"]["max_header_length"]; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password. */ + readonly dbUrl: string; + readonly jwtSecret: string; + readonly jwks: string; +} + +/** + * Builds the `docker create` spec for the Realtime container + * (`start.go:903-958`). No `ports` (host-published) entry — Realtime, like + * GoTrue, only ever `ExposedPorts` its port on the Docker network + * (`nat.PortSet{"4000/tcp": {}}`, `start.go:930`). + */ +export function legacyBuildRealtimeContainerSpec( + input: LegacyRealtimeContainerSpecInput, +): LegacyStartContainerSpec { + const env = legacyBuildRealtimeEnv({ + ipVersion: input.ipVersion, + maxHeaderLength: input.maxHeaderLength, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + jwtSecret: input.jwtSecret, + jwks: input.jwks, + }); + + return { + image: input.image, + containerName: legacyServiceContainerName("realtime", input.projectId), + env, + binds: [], + exposedPorts: [{ containerPort: "4000" }], + healthcheck: { + // Podman splits command by spaces unless quoted, but curl's header can't be quoted + // (`start.go:932`, reproduced verbatim as this exec-form `test` array). + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + `Host:${LEGACY_REALTIME_TENANT_ID}`, + "http://127.0.0.1:4000/api/ping", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.RealtimeAliases = []string{"realtime", Config.Realtime.TenantId}` (`utils/config.go:40`). + networkAliases: ["realtime", LEGACY_REALTIME_TENANT_ID], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts new file mode 100644 index 0000000000..dfbf24b95d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyBuildRealtimeContainerSpec, + legacyBuildRealtimeEnv, + type LegacyRealtimeContainerSpecInput, +} from "./realtime.service.ts"; + +describe("legacyBuildRealtimeEnv", () => { + const base = { + ipVersion: "IPv4" as const, + maxHeaderLength: 4096, + dbHost: "supabase_db_proj", + dbPassword: "postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + }; + + test("wires the fixed internal DB address, JWT secret, and JWKS", () => { + const env = legacyBuildRealtimeEnv(base); + expect(env["DB_HOST"]).toBe("supabase_db_proj"); + expect(env["DB_PORT"]).toBe("5432"); + expect(env["DB_USER"]).toBe("supabase_admin"); + expect(env["DB_PASSWORD"]).toBe("postgres"); + expect(env["DB_NAME"]).toBe("postgres"); + expect(env["API_JWT_SECRET"]).toBe(base.jwtSecret); + expect(env["METRICS_JWT_SECRET"]).toBe(base.jwtSecret); + expect(env["API_JWT_JWKS"]).toBe(base.jwks); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildRealtimeEnv(base); + expect(env).toMatchObject({ + PORT: "4000", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: "supabaserealtime", + APP_NAME: "realtime", + SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + DNS_NODES: "''", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: "4096", + }); + }); + + test("selects inet_tcp for IPv4", () => { + expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv4" })["ERL_AFLAGS"]).toBe( + "-proto_dist inet_tcp", + ); + }); + + test("selects inet6_tcp for IPv6", () => { + expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv6" })["ERL_AFLAGS"]).toBe( + "-proto_dist inet6_tcp", + ); + }); +}); + +describe("legacyBuildRealtimeContainerSpec", () => { + const input: LegacyRealtimeContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/realtime:v2", + ipVersion: "IPv4", + maxHeaderLength: 4096, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + }; + + test("derives the container name and internal DB host from projectId", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.containerName).toBe("supabase_realtime_proj"); + expect(spec.env["DB_HOST"]).toBe("supabase_db_proj"); + expect(spec.env["DB_PASSWORD"]).toBe("postgres"); + }); + + test("exposes port 4000 with no host-published port binding", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.exposedPorts).toEqual([{ containerPort: "4000" }]); + expect(spec.ports).toBeUndefined(); + }); + + test("builds the exec-form healthcheck with the tenant id host header", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + "Host:realtime-dev", + "http://127.0.0.1:4000/api/ping", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("network aliases are 'realtime' and the tenant id", () => { + const spec = legacyBuildRealtimeContainerSpec(input); + expect(spec.networkAliases).toEqual(["realtime", "realtime-dev"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("reuses (does not recompute) the resolved db password from a non-default dbUrl", () => { + const spec = legacyBuildRealtimeContainerSpec({ + ...input, + dbUrl: "postgresql://postgres:another-secret@127.0.0.1:54322/postgres", + }); + expect(spec.env["DB_PASSWORD"]).toBe("another-secret"); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts new file mode 100644 index 0000000000..439a465b9e --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -0,0 +1,266 @@ +/** + * Port of Go's "Start Storage" block + * (`apps/cli-go/internal/start/start.go:994-1057`), plus the vector-bucket + * env helper `appendStorageVectorEnv` (`start.go:1487-1501`). + * + * Enabled gate: `isStorageEnabled` (`start.go:301`) — + * `config.storage.enabled && !isContainerExcluded(storageImage, excluded)`. + * Gating itself is the caller's responsibility (`start.services.ts`'s + * `storage` catalog entry, `enabledGate: "storage.enabled"`); this module + * only builds the container spec once called. + * + * `imageTransformationEnabled` (below) is Go's `isImgProxyEnabled` — a + * COMPOUND value (`storage.enabled` is already implied by the fact Storage + * itself is starting) `&& config.storage.image_transformation?.enabled && + * !isContainerExcluded(imgproxyImage, excluded)` (`start.go:302-303`), NOT + * the bare `config.storage.image_transformation?.enabled` field alone. The + * caller must compute this compound boolean exactly once and pass the SAME + * value both here (`ENABLE_IMAGE_TRANSFORMATION`) and to the decision of + * whether to actually start the ImgProxy container + * (`imgproxy.service.ts`'s `legacyBuildImgproxyContainerSpec` precondition) + * — the two must never disagree, matching Go's single shared + * `isImgProxyEnabled` local variable (`start.go:995,1011,1060`). + * + * `s3ProtocolEnabled` is `config.storage.s3_protocol.enabled` directly (no + * exclusion factor — S3 protocol support is a Storage feature flag, not a + * separate container). Go models `S3Protocol` as a nilable pointer + * (`*s3Protocol`, `pkg/config/storage.go:17`) that stays `nil` only when + * `[storage.s3_protocol]` is entirely absent from BOTH the user's + * `config.toml` and the embedded default template — but the embedded + * template always sets `[storage.s3_protocol]\nenabled = true` + * (`pkg/config/templates/config.toml:128-129`) and is merged in as the base + * layer before any real config loads, so `S3Protocol` is non-nil for every + * config `Config.Load` actually produces. `@supabase/config`'s schema + * mirrors this by decoding `storage.s3_protocol` unconditionally (never + * `optionalKey`, unlike `image_transformation`), so the raw decoded boolean + * needs no extra PRESENCE check — but the caller is still responsible for + * applying `SUPABASE_STORAGE_S3_PROTOCOL_ENABLED`/`SUPABASE_STORAGE_VECTOR_ + * ENABLED` (Go's generic Viper `AutomaticEnv` override, same mechanism + * `imageTransformationEnabled`'s own compound gate already accounts for) + * before passing `s3ProtocolEnabled`/`vectorBucketsEnabled` in. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; +import { + legacyStartInternalDbUrl, + legacyStartInternalDbPassword, +} from "../lib/internal-db-connection.ts"; + +/** Go's `dockerStoragePath` local (`start.go:996`) — both the container's `FILE_STORAGE_BACKEND_PATH` and its named-volume mount target. */ +const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; + +export interface LegacyStorageVectorEnvInput { + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + readonly dbPassword: string; + readonly projectEnvValues?: Readonly>; +} + +/** + * Port of `appendStorageVectorEnv(env []string, dbConfig pgconn.Config) + * []string` (`start.go:1473-1501`). Only called when + * `config.storage.vector.enabled` (Go's `isVectorBucketsEnabled`, + * `start.go:305,1022-1024`) — note the TOML key is `[storage.vector]`, not + * `vector_buckets`; Go's Go-side struct field is named `VectorBuckets` but + * tagged `toml:"vector"` (`pkg/config/storage.go:21`). + */ +export function legacyAppendStorageVectorEnv( + env: Readonly>, + input: LegacyStorageVectorEnvInput, +): Record { + const defaultVectorUrl = legacyStartInternalDbUrl("postgres", input.dbHost, input.dbPassword); + return { + ...env, + VECTOR_ENABLED: legacyEnvOrDefault("VECTOR_ENABLED", "true", input.projectEnvValues), + VECTOR_BUCKET_PROVIDER: legacyEnvOrDefault( + "VECTOR_BUCKET_PROVIDER", + "pgvector", + input.projectEnvValues, + ), + VECTOR_STORE_MIGRATIONS_ENABLED: legacyEnvOrDefault( + "VECTOR_STORE_MIGRATIONS_ENABLED", + "true", + input.projectEnvValues, + ), + VECTOR_DATABASE_URL: legacyEnvOrDefault( + "VECTOR_DATABASE_URL", + defaultVectorUrl, + input.projectEnvValues, + ), + }; +} + +export interface LegacyStorageEnvInput { + /** + * Go's `utils.Config.Storage.TargetMigration` (`pkg/config/storage.go:13`) — + * `toml:"-"`, resolved from a version-pin file + * (`builder.StorageMigrationPath`, `config.go:844-846`), not from + * `@supabase/config`'s schema. Out of scope for this builder (like `image`); + * the caller resolves it and typically passes `""` when the file is absent, + * matching Go's zero-value string default. + */ + readonly targetMigration: string; + /** `LegacyLocalConfigValues.anonKey`. */ + readonly anonKey: string; + /** `LegacyLocalConfigValues.serviceRoleKey`. */ + readonly serviceRoleKey: string; + /** `LegacyLocalConfigValues.jwtSecret`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string. */ + readonly jwks: string; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + readonly dbPassword: string; + /** `config.storage.file_size_limit`, e.g. `"50MiB"` — converted to a byte count via `ramInBytes`. */ + readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; + /** `LegacyLocalConfigValues.storageS3Region`. */ + readonly s3Region: string; + /** `LegacyLocalConfigValues.storageS3AccessKeyId`. */ + readonly s3AccessKeyId: string; + /** `LegacyLocalConfigValues.storageS3SecretAccessKey`. */ + readonly s3SecretAccessKey: string; + /** Go's compound `isImgProxyEnabled` — see this file's header for why this is NOT the bare config field. */ + readonly imageTransformationEnabled: boolean; + /** The ImgProxy container's own Docker name (`legacyServiceContainerName("imgproxy", projectId)`). */ + readonly imgproxyHost: string; + /** `config.storage.s3_protocol.enabled` — see this file's header for why no extra presence check is needed. */ + readonly s3ProtocolEnabled: boolean; + /** `config.storage.vector.enabled` (Go's `isVectorBucketsEnabled`). */ + readonly vectorBucketsEnabled: boolean; + readonly projectEnvValues?: Readonly>; +} + +/** + * Pure env-var builder, split out from {@link legacyBuildStorageContainerSpec} + * so the full Go `storageEnv` literal (`start.go:997-1021`) — including the + * conditional vector-bucket branch — is unit-testable without constructing a + * whole container spec. + */ +export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record { + const env: Record = { + DB_MIGRATIONS_FREEZE_AT: input.targetMigration, + ANON_KEY: input.anonKey, + SERVICE_KEY: input.serviceRoleKey, + AUTH_JWT_SECRET: input.jwtSecret, + JWT_JWKS: input.jwks, + DATABASE_URL: legacyStartInternalDbUrl( + "supabase_storage_admin", + input.dbHost, + input.dbPassword, + ), + FILE_SIZE_LIMIT: String(ramInBytes(input.fileSizeLimit)), + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: LEGACY_STORAGE_DOCKER_PATH, + TENANT_ID: "stub", + // TODO (matches Go's own TODO, `start.go:1008`): https://github.com/supabase/storage-api/issues/55 + STORAGE_S3_REGION: input.s3Region, + GLOBAL_S3_BUCKET: "stub", + ENABLE_IMAGE_TRANSFORMATION: String(input.imageTransformationEnabled), + IMGPROXY_URL: `http://${input.imgproxyHost}:5001`, + TUS_URL_PATH: "/storage/v1/upload/resumable", + S3_PROTOCOL_ENABLED: String(input.s3ProtocolEnabled), + S3_PROTOCOL_ACCESS_KEY_ID: input.s3AccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: input.s3SecretAccessKey, + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + }; + + return input.vectorBucketsEnabled + ? legacyAppendStorageVectorEnv(env, { + dbHost: input.dbHost, + dbPassword: input.dbPassword, + projectEnvValues: input.projectEnvValues, + }) + : env; +} + +export interface LegacyStorageContainerSpecInput { + /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ + readonly projectId: string; + /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + readonly networkId: string; + /** `utils.Config.Storage.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + readonly image: string; + readonly targetMigration: string; + readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; + readonly s3Region: string; + readonly s3AccessKeyId: string; + readonly s3SecretAccessKey: string; + readonly s3ProtocolEnabled: boolean; + readonly imageTransformationEnabled: boolean; + readonly vectorBucketsEnabled: boolean; + /** `LegacyLocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB password. */ + readonly dbUrl: string; + readonly jwtSecret: string; + readonly jwks: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly projectEnvValues?: Readonly>; +} + +/** + * Builds the `docker create` spec for the Storage container + * (`start.go:994-1057`). `binds` mounts the container's own named volume at + * `/mnt` (`container.HostConfig.Binds: []string{utils.StorageId + ":" + + * dockerStoragePath}`, `start.go:1043`) — no `ports`/`exposedPorts`, Storage + * is reached only via its Docker network alias. + */ +export function legacyBuildStorageContainerSpec( + input: LegacyStorageContainerSpecInput, +): LegacyStartContainerSpec { + const containerName = legacyServiceContainerName("storage", input.projectId); + const env = legacyBuildStorageEnv({ + targetMigration: input.targetMigration, + anonKey: input.anonKey, + serviceRoleKey: input.serviceRoleKey, + jwtSecret: input.jwtSecret, + jwks: input.jwks, + dbHost: legacyServiceContainerName("db", input.projectId), + dbPassword: legacyStartInternalDbPassword(input.dbUrl), + fileSizeLimit: input.fileSizeLimit, + s3Region: input.s3Region, + s3AccessKeyId: input.s3AccessKeyId, + s3SecretAccessKey: input.s3SecretAccessKey, + imageTransformationEnabled: input.imageTransformationEnabled, + imgproxyHost: legacyServiceContainerName("imgproxy", input.projectId), + s3ProtocolEnabled: input.s3ProtocolEnabled, + vectorBucketsEnabled: input.vectorBucketsEnabled, + projectEnvValues: input.projectEnvValues, + }); + + return { + image: input.image, + containerName, + env, + binds: [`${containerName}:${LEGACY_STORAGE_DOCKER_PATH}`], + healthcheck: { + // "For some reason, localhost resolves to IPv6 address on GitPod which breaks + // healthcheck." (`start.go:1031`) — reproduced verbatim, IPv4 loopback pinned. + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + restartPolicy: "unless-stopped", + networkId: input.networkId, + // `utils.StorageAliases = []string{"storage"}` (`utils/config.go:42`). + networkAliases: ["storage"], + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts new file mode 100644 index 0000000000..0efe020753 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/storage.service.unit.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyAppendStorageVectorEnv, + legacyBuildStorageContainerSpec, + legacyBuildStorageEnv, + type LegacyStorageContainerSpecInput, + type LegacyStorageEnvInput, +} from "./storage.service.ts"; + +const baseEnvInput: LegacyStorageEnvInput = { + targetMigration: "", + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + dbHost: "supabase_db_proj", + dbPassword: "postgres", + fileSizeLimit: "50MiB", + s3Region: "local", + s3AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + s3SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + imageTransformationEnabled: false, + imgproxyHost: "supabase_imgproxy_proj", + s3ProtocolEnabled: true, + vectorBucketsEnabled: false, +}; + +describe("legacyBuildStorageEnv", () => { + test("wires the resolved auth keys, JWT secret, and JWKS", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env["ANON_KEY"]).toBe("anon-key"); + expect(env["SERVICE_KEY"]).toBe("service-role-key"); + expect(env["AUTH_JWT_SECRET"]).toBe(baseEnvInput.jwtSecret); + expect(env["JWT_JWKS"]).toBe(baseEnvInput.jwks); + }); + + test("wires DATABASE_URL as the supabase_storage_admin role against the internal DB address", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env["DATABASE_URL"]).toBe( + "postgresql://supabase_storage_admin:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("wires the resolved S3 credentials, not hardcoded literals", () => { + const env = legacyBuildStorageEnv({ + ...baseEnvInput, + s3AccessKeyId: "custom-access-key", + s3SecretAccessKey: "custom-secret-key", + s3Region: "custom-region", + }); + expect(env["S3_PROTOCOL_ACCESS_KEY_ID"]).toBe("custom-access-key"); + expect(env["S3_PROTOCOL_ACCESS_KEY_SECRET"]).toBe("custom-secret-key"); + expect(env["STORAGE_S3_REGION"]).toBe("custom-region"); + }); + + test("converts file_size_limit from a human-readable string to a byte count", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, fileSizeLimit: "5MiB" }); + expect(env["FILE_SIZE_LIMIT"]).toBe(String(5 * 1024 * 1024)); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env).toMatchObject({ + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: "/mnt", + TENANT_ID: "stub", + GLOBAL_S3_BUCKET: "stub", + TUS_URL_PATH: "/storage/v1/upload/resumable", + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + }); + }); + + describe("image-transformation / ImgProxy gate", () => { + test("ENABLE_IMAGE_TRANSFORMATION and IMGPROXY_URL reflect the caller-resolved compound flag", () => { + const disabled = legacyBuildStorageEnv({ + ...baseEnvInput, + imageTransformationEnabled: false, + }); + expect(disabled["ENABLE_IMAGE_TRANSFORMATION"]).toBe("false"); + + const enabled = legacyBuildStorageEnv({ ...baseEnvInput, imageTransformationEnabled: true }); + expect(enabled["ENABLE_IMAGE_TRANSFORMATION"]).toBe("true"); + }); + + test("IMGPROXY_URL always points at the imgproxy container regardless of the gate", () => { + const env = legacyBuildStorageEnv(baseEnvInput); + expect(env["IMGPROXY_URL"]).toBe("http://supabase_imgproxy_proj:5001"); + }); + }); + + describe("S3 protocol gate", () => { + test("S3_PROTOCOL_ENABLED reflects config.storage.s3_protocol.enabled directly", () => { + expect( + legacyBuildStorageEnv({ ...baseEnvInput, s3ProtocolEnabled: true })["S3_PROTOCOL_ENABLED"], + ).toBe("true"); + expect( + legacyBuildStorageEnv({ ...baseEnvInput, s3ProtocolEnabled: false })["S3_PROTOCOL_ENABLED"], + ).toBe("false"); + }); + }); + + describe("vector-buckets env branch", () => { + test("omits every VECTOR_* key when vectorBucketsEnabled is false", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, vectorBucketsEnabled: false }); + expect(env["VECTOR_ENABLED"]).toBeUndefined(); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBeUndefined(); + expect(env["VECTOR_STORE_MIGRATIONS_ENABLED"]).toBeUndefined(); + expect(env["VECTOR_DATABASE_URL"]).toBeUndefined(); + }); + + test("appends the four VECTOR_* keys with Go's defaults when enabled and no override is set", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, vectorBucketsEnabled: true }); + expect(env["VECTOR_ENABLED"]).toBe("true"); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBe("pgvector"); + expect(env["VECTOR_STORE_MIGRATIONS_ENABLED"]).toBe("true"); + expect(env["VECTOR_DATABASE_URL"]).toBe( + "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", + ); + }); + + test("VECTOR_DATABASE_URL defaults to the postgres role, distinct from DATABASE_URL's storage_admin role", () => { + const env = legacyBuildStorageEnv({ ...baseEnvInput, vectorBucketsEnabled: true }); + expect(env["VECTOR_DATABASE_URL"]).not.toBe(env["DATABASE_URL"]); + expect(env["VECTOR_DATABASE_URL"]).toContain("postgres:postgres@"); + expect(env["DATABASE_URL"]).toContain("supabase_storage_admin:postgres@"); + }); + + test("respects a projectEnvValues override over the default, matching Go's envOrDefault", () => { + const env = legacyBuildStorageEnv({ + ...baseEnvInput, + vectorBucketsEnabled: true, + projectEnvValues: { VECTOR_ENABLED: "false", VECTOR_BUCKET_PROVIDER: "custom" }, + }); + expect(env["VECTOR_ENABLED"]).toBe("false"); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBe("custom"); + // Unoverridden keys still fall back to Go's defaults. + expect(env["VECTOR_STORE_MIGRATIONS_ENABLED"]).toBe("true"); + }); + + test("an override that is set but empty is used verbatim, matching os.LookupEnv (not treated as unset)", () => { + const env = legacyBuildStorageEnv({ + ...baseEnvInput, + vectorBucketsEnabled: true, + projectEnvValues: { VECTOR_BUCKET_PROVIDER: "" }, + }); + expect(env["VECTOR_BUCKET_PROVIDER"]).toBe(""); + }); + }); +}); + +describe("legacyAppendStorageVectorEnv", () => { + test("preserves the base env and appends the four vector keys", () => { + const base = { EXISTING_KEY: "unchanged" }; + const appended = legacyAppendStorageVectorEnv(base, { + dbHost: "supabase_db_proj", + dbPassword: "postgres", + }); + expect(appended["EXISTING_KEY"]).toBe("unchanged"); + expect(Object.keys(appended)).toEqual( + expect.arrayContaining([ + "VECTOR_ENABLED", + "VECTOR_BUCKET_PROVIDER", + "VECTOR_STORE_MIGRATIONS_ENABLED", + "VECTOR_DATABASE_URL", + ]), + ); + }); +}); + +describe("legacyBuildStorageContainerSpec", () => { + const input: LegacyStorageContainerSpecInput = { + projectId: "proj", + networkId: "supabase_network_proj", + image: "supabase/storage-api:v1", + targetMigration: "", + fileSizeLimit: "50MiB", + s3Region: "local", + s3AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + s3SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + s3ProtocolEnabled: true, + imageTransformationEnabled: false, + vectorBucketsEnabled: false, + dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + }; + + test("derives the container name, DB host, and imgproxy host from projectId", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.containerName).toBe("supabase_storage_proj"); + expect(spec.env["DATABASE_URL"]).toBe( + "postgresql://supabase_storage_admin:postgres@supabase_db_proj:5432/postgres", + ); + expect(spec.env["IMGPROXY_URL"]).toBe("http://supabase_imgproxy_proj:5001"); + }); + + test("mounts its own named volume at /mnt, with no ports/exposedPorts", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.binds).toEqual(["supabase_storage_proj:/mnt"]); + expect(spec.ports).toBeUndefined(); + expect(spec.exposedPorts).toBeUndefined(); + }); + + test("builds the wget-based healthcheck against the IPv4 loopback", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + }); + + test("network alias is 'storage'", () => { + const spec = legacyBuildStorageContainerSpec(input); + expect(spec.networkAliases).toEqual(["storage"]); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + }); + + test("propagates the image-transformation gate through to ENABLE_IMAGE_TRANSFORMATION", () => { + const withImgproxy = legacyBuildStorageContainerSpec({ + ...input, + imageTransformationEnabled: true, + }); + expect(withImgproxy.env["ENABLE_IMAGE_TRANSFORMATION"]).toBe("true"); + + const withoutImgproxy = legacyBuildStorageContainerSpec({ + ...input, + imageTransformationEnabled: false, + }); + expect(withoutImgproxy.env["ENABLE_IMAGE_TRANSFORMATION"]).toBe("false"); + }); + + test("propagates the vector-buckets flag through to the container env", () => { + const spec = legacyBuildStorageContainerSpec({ ...input, vectorBucketsEnabled: true }); + expect(spec.env["VECTOR_ENABLED"]).toBe("true"); + expect(spec.env["VECTOR_DATABASE_URL"]).toBe( + "postgresql://postgres:postgres@supabase_db_proj:5432/postgres", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts new file mode 100644 index 0000000000..56007b5a9d --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -0,0 +1,221 @@ +/** + * Studio env + container spec builder — port of Go's "Start Studio" block + * (`apps/cli-go/internal/start/start.go:1148-1191`) and its `buildStudioEnv` + * helper (`start.go:1319-1347`), ported faithfully as {@link buildLegacyStudioEnv} + * — see `apps/cli-go/internal/start/start_test.go:522 TestBuildStudioEnv`, + * ported in `studio.service.unit.test.ts`. Gated in Go by `config.studio.enabled` + * and `!isContainerExcluded(config.studio.image, excluded)` — see + * `legacy-service-catalog.ts`'s `studio` entry (`excludeKey: "studio"`, gated + * on `studio.enabled`, depends on pg-meta being healthy/running). Gating, + * image resolution/pre-pull, and edge-function bind-mount resolution + * (`serve.PopulatePerFunctionConfigs`, out of scope here — see + * {@link LegacyStudioContainerInput.functionBinds}) are the caller's job (a + * future `start.handler.ts`). + * + * `workdir`/`containerSnippetsPath`: Go computes `hostSnippetsPath := + * filepath.Join(workdir, utils.SnippetsDir)` where `utils.SnippetsDir = + * filepath.Join("supabase", "snippets")` (`apps/cli-go/internal/utils/misc.go:107`) + * — a plain top-level `/supabase/snippets` directory, NOT under + * `supabase/.temp/` (that directory is reserved for Go's own link-state cache, + * see `legacy-temp-paths.ts`; snippets are user content Studio's SQL Editor + * reads/writes, meant to persist, not a cache). `containerSnippetsPath` is + * that host path translated to its in-container mount form + * (`utils.ToDockerPath` — see {@link legacyToDockerPath}), computed once by + * {@link buildLegacyStudioContainerSpec} and threaded into both the bind mount + * and {@link buildLegacyStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`, mirroring + * Go's `run()` computing it once for both uses. + */ + +import { join } from "node:path"; + +import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; +import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; + +/** Container-internal port Studio listens on — Go's hardcoded `3000/tcp` (`start.go:1166,1174`). */ +const STUDIO_CONTAINER_PORT = 3000; + +/** Go's `utils.StudioAliases` (`apps/cli-go/internal/utils/config.go:45`) — a fixed, non-configurable constant. */ +const STUDIO_NETWORK_ALIASES = ["studio"]; + +/** + * Go's `Config.Analytics.ApiKey` default (`pkg/config/config.go:529`, + * `NewConfig()`'s `Analytics: analytics{ApiKey: "api-key", ...}`). That field + * is `toml:"-"` (`pkg/config/config.go:307`) — never decoded from + * `config.toml`, never overridable — so this is always the value regardless of + * the resolved project config, exactly like `legacy-local-config-values.ts`'s + * other Go-hardcoded, schema-absent constants (`DEFAULT_DB_PASSWORD`, the S3 + * credential triple). + */ +const LOGFLARE_PRIVATE_ACCESS_TOKEN = "api-key"; + +export interface LegacyBuildStudioEnvInput { + /** Go's `dbConfig.Password` (`start.go:1323`) — becomes `POSTGRES_PASSWORD`. */ + readonly dbPassword: string; + /** + * Go's `workdir` (`os.Getwd()` in `run()`, `start.go:308`; matches + * `LegacyCliConfig.workdir`, the already-resolved absolute project root) — + * `EDGE_FUNCTIONS_MANAGEMENT_FOLDER` is resolved against it + * (`filepath.Join(workdir, utils.FunctionsDir)`, `start.go:1341`). + */ + readonly workdir: string; + /** + * Go's `containerSnippetsPath` (`start.go:1157`) — becomes + * `SNIPPETS_MANAGEMENT_FOLDER` verbatim. See this module's doc comment for + * what it is and how it's derived. + */ + readonly containerSnippetsPath: string; + /** Go's `utils.Version` — `CURRENT_CLI_VERSION`. */ + readonly cliVersion: string; + /** pg-meta's own container name (Go's `utils.PgmetaId`) — `STUDIO_PG_META_URL=http://:8080`. */ + readonly pgMetaContainerName: string; + /** Kong's own container name (Go's `utils.KongId`) — `SUPABASE_URL=http://:8000`. */ + readonly kongContainerName: string; + /** Logflare's own container name (Go's `utils.LogflareId`) — `LOGFLARE_URL=http://:4000`. */ + readonly logflareContainerName: string; + /** + * `config.studio.api_url`, post-`SUPABASE_STUDIO_API_URL`-override AND + * post-`Config.Validate`'s host-rewrite (`legacyResolveStudioApiUrl`, + * `pkg/config/config.go:1074-1078`) — `SUPABASE_PUBLIC_URL`. Distinct from + * `LegacyLocalConfigValues.studioUrl` (the `http://:` value + * `status` reports). This is NOT simply the raw `studio.api_url` field: + * under a default config its host (`127.0.0.1`) matches the local hostname, + * so Go rewrites it to `Config.Api.ExternalUrl` (the Kong URL) before + * `start` ever reads it — the caller must apply that same rewrite before + * passing this field in. + */ + readonly studioApiUrl: string; + /** `legacyResolveLocalConfigValues(...).jwtSecret` — `AUTH_JWT_SECRET`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalConfigValues(...).anonKey` — `SUPABASE_ANON_KEY`. */ + readonly anonKey: string; + /** `legacyResolveLocalConfigValues(...).serviceRoleKey` — `SUPABASE_SERVICE_KEY`. */ + readonly serviceRoleKey: string; + /** `legacyResolveLocalConfigValues(...).publishableKey` — `SUPABASE_PUBLISHABLE_KEY`. */ + readonly publishableKey: string; + /** `legacyResolveLocalConfigValues(...).secretKey` — `SUPABASE_SECRET_KEY`. */ + readonly secretKey: string; + /** `legacyResolveLocalConfigValues(...).storageS3AccessKeyId` — `S3_PROTOCOL_ACCESS_KEY_ID`. */ + readonly s3AccessKeyId: string; + /** `legacyResolveLocalConfigValues(...).storageS3SecretAccessKey` — `S3_PROTOCOL_ACCESS_KEY_SECRET`. */ + readonly s3SecretAccessKey: string; + /** + * `config.studio.openai_api_key`, decrypted/resolved by the caller — + * `OPENAI_API_KEY`. `undefined` maps to `""`, matching Go's unset `Secret` + * zero value (`utils.Config.Studio.OpenaiApiKey.Value`). + */ + readonly openaiApiKey: string | undefined; + /** `config.api.schemas` — `PGRST_DB_SCHEMAS`, comma-joined. */ + readonly apiSchemas: ReadonlyArray; + /** `config.api.extra_search_path` — `PGRST_DB_EXTRA_SEARCH_PATH`, comma-joined. */ + readonly apiExtraSearchPath: ReadonlyArray; + /** `config.api.max_rows` — `PGRST_DB_MAX_ROWS`. */ + readonly apiMaxRows: number; + /** `legacyEnvOverrideBool`-resolved `analytics.enabled` — `NEXT_PUBLIC_ENABLE_LOGS`. */ + readonly analyticsEnabled: boolean; + /** `config.analytics.backend`, post-`SUPABASE_ANALYTICS_BACKEND`-override — `NEXT_ANALYTICS_BACKEND_PROVIDER`. */ + readonly analyticsBackend: "postgres" | "bigquery"; +} + +/** + * Port of Go's `buildStudioEnv(dbConfig pgconn.Config, workdir, + * containerSnippetsPath string) []string` (`start.go:1319-1347`). Returns a + * `KEY -> value` map rather than Go's `KEY=value` string slice — + * {@link LegacyStartContainerSpec.env}'s own shape, chosen so secret values + * never round-trip through this process's own `docker create` argv (see that + * field's doc comment in `docker-create-args.ts`). Pure — no Effect or I/O — + * so every env var mapping is unit-testable in isolation; ported test: + * `apps/cli-go/internal/start/start_test.go:522 TestBuildStudioEnv`. + */ +export function buildLegacyStudioEnv(input: LegacyBuildStudioEnvInput): Record { + return { + CURRENT_CLI_VERSION: input.cliVersion, + STUDIO_PG_META_URL: `http://${input.pgMetaContainerName}:8080`, + POSTGRES_PASSWORD: input.dbPassword, + SUPABASE_URL: `http://${input.kongContainerName}:8000`, + SUPABASE_PUBLIC_URL: input.studioApiUrl, + AUTH_JWT_SECRET: input.jwtSecret, + SUPABASE_ANON_KEY: input.anonKey, + SUPABASE_SERVICE_KEY: input.serviceRoleKey, + SUPABASE_PUBLISHABLE_KEY: input.publishableKey, + SUPABASE_SECRET_KEY: input.secretKey, + S3_PROTOCOL_ACCESS_KEY_ID: input.s3AccessKeyId, + S3_PROTOCOL_ACCESS_KEY_SECRET: input.s3SecretAccessKey, + LOGFLARE_PRIVATE_ACCESS_TOKEN, + OPENAI_API_KEY: input.openaiApiKey ?? "", + PGRST_DB_SCHEMAS: input.apiSchemas.join(","), + PGRST_DB_EXTRA_SEARCH_PATH: input.apiExtraSearchPath.join(","), + PGRST_DB_MAX_ROWS: String(input.apiMaxRows), + LOGFLARE_URL: `http://${input.logflareContainerName}:4000`, + NEXT_PUBLIC_ENABLE_LOGS: String(input.analyticsEnabled), + NEXT_ANALYTICS_BACKEND_PROVIDER: input.analyticsBackend, + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: legacyToDockerPath( + join(input.workdir, "supabase", "functions"), + ), + SNIPPETS_MANAGEMENT_FOLDER: input.containerSnippetsPath, + // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 + HOSTNAME: "0.0.0.0", + POSTGRES_USER_READ_WRITE: "postgres", + }; +} + +export interface LegacyStudioContainerInput { + /** `config.studio.image`, already resolved/pulled by the caller. */ + readonly image: string; + /** `legacyServiceContainerName("studio", projectId)` — Go's `utils.StudioId`. */ + readonly containerName: string; + /** Go's `utils.NetId` — the shared Docker network every `start` container joins. */ + readonly networkId: string; + /** `config.studio.port` — the host port published to `3000/tcp`. */ + readonly port: number; + /** + * Go's `serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys)` binds + * (`start.go:1150`) — the per-enabled-Edge-Function module bind mounts Go + * resolves from each `supabase/functions/`'s deploy config. Out of + * scope for this port (a separate, large functions-deploy-config-parsing + * concern); pass `[]` until a future edge-functions-serve integration + * supplies these. + */ + readonly functionBinds: ReadonlyArray; + /** Every value {@link buildLegacyStudioEnv} needs, minus the path this builder derives itself. */ + readonly env: Omit; +} + +/** + * Assembles Studio's {@link LegacyStartContainerSpec}, including the snippets + * bind mount and its Docker-path form Go derives from `workdir` once + * (`start.go:1150-1159`) and reuses for both the bind and + * {@link buildLegacyStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`. + */ +export function buildLegacyStudioContainerSpec( + input: LegacyStudioContainerInput, +): LegacyStartContainerSpec { + const hostSnippetsPath = join(input.env.workdir, "supabase", "snippets"); + const containerSnippetsPath = legacyToDockerPath(hostSnippetsPath); + + // Go's `utils.RemoveDuplicates` (`start.go:1159`) — order-preserving dedup; + // `Set` iteration order matches Go's own first-seen-wins semantics. + const binds = Array.from( + new Set([...input.functionBinds, `${hostSnippetsPath}:${containerSnippetsPath}:rw`]), + ); + + return { + image: input.image, + containerName: input.containerName, + env: buildLegacyStudioEnv({ ...input.env, containerSnippetsPath }), + binds, + healthcheck: { + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:${STUDIO_CONTAINER_PORT}/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }, + ports: [{ hostPort: String(input.port), containerPort: String(STUDIO_CONTAINER_PORT) }], + restartPolicy: "unless-stopped", + networkId: input.networkId, + networkAliases: STUDIO_NETWORK_ALIASES, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts new file mode 100644 index 0000000000..37918f6b48 --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "vitest"; + +import { + buildLegacyStudioContainerSpec, + buildLegacyStudioEnv, + type LegacyBuildStudioEnvInput, +} from "./studio.service.ts"; + +// Mirrors the fixture Go's `TestBuildStudioEnv` builds via `config.NewConfig()` +// plus its explicit field overrides (`apps/cli-go/internal/start/start_test.go:522-564`), +// translated into this pure function's explicit input shape. +const baseEnvInput: LegacyBuildStudioEnvInput = { + dbPassword: "postgres", + workdir: "/project", + containerSnippetsPath: "/project/supabase/.temp/snippets", + cliVersion: "test-version", + pgMetaContainerName: "test-pgmeta", + kongContainerName: "test-kong", + logflareContainerName: "test-logflare", + studioApiUrl: "http://127.0.0.1:54321", + jwtSecret: "jwt-secret", + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + s3AccessKeyId: "s3-access-key", + s3SecretAccessKey: "s3-secret-key", + openaiApiKey: undefined, + apiSchemas: ["public", "graphql_public"], + apiExtraSearchPath: ["public", "extensions"], + apiMaxRows: 1000, + analyticsEnabled: true, + analyticsBackend: "postgres", +}; + +describe("buildLegacyStudioEnv", () => { + test("mirrors Go's TestBuildStudioEnv fixture", () => { + const env = buildLegacyStudioEnv(baseEnvInput); + + // The exact 8 assertions `TestBuildStudioEnv` makes. + expect(env["SUPABASE_ANON_KEY"]).toBe("anon-key"); + expect(env["SUPABASE_SERVICE_KEY"]).toBe("service-role-key"); + expect(env["SUPABASE_PUBLISHABLE_KEY"]).toBe("sb_publishable_test"); + expect(env["SUPABASE_SECRET_KEY"]).toBe("sb_secret_test"); + expect(env["S3_PROTOCOL_ACCESS_KEY_ID"]).toBe("s3-access-key"); + expect(env["S3_PROTOCOL_ACCESS_KEY_SECRET"]).toBe("s3-secret-key"); + expect(env["SUPABASE_URL"]).toBe("http://test-kong:8000"); + expect(env["STUDIO_PG_META_URL"]).toBe("http://test-pgmeta:8080"); + + // Every other key `buildStudioEnv` emits (`start.go:1319-1347`), not + // asserted by the Go test but covered here for full parity. + expect(env).toEqual({ + CURRENT_CLI_VERSION: "test-version", + STUDIO_PG_META_URL: "http://test-pgmeta:8080", + POSTGRES_PASSWORD: "postgres", + SUPABASE_URL: "http://test-kong:8000", + SUPABASE_PUBLIC_URL: "http://127.0.0.1:54321", + AUTH_JWT_SECRET: "jwt-secret", + SUPABASE_ANON_KEY: "anon-key", + SUPABASE_SERVICE_KEY: "service-role-key", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_SECRET_KEY: "sb_secret_test", + S3_PROTOCOL_ACCESS_KEY_ID: "s3-access-key", + S3_PROTOCOL_ACCESS_KEY_SECRET: "s3-secret-key", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + OPENAI_API_KEY: "", + PGRST_DB_SCHEMAS: "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", + PGRST_DB_MAX_ROWS: "1000", + LOGFLARE_URL: "http://test-logflare:4000", + NEXT_PUBLIC_ENABLE_LOGS: "true", + NEXT_ANALYTICS_BACKEND_PROVIDER: "postgres", + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: "/project/supabase/functions", + SNIPPETS_MANAGEMENT_FOLDER: "/project/supabase/.temp/snippets", + HOSTNAME: "0.0.0.0", + POSTGRES_USER_READ_WRITE: "postgres", + }); + }); + + test("LOGFLARE_PRIVATE_ACCESS_TOKEN is always Go's hardcoded 'api-key', regardless of input", () => { + // `analytics.api_key` isn't a `config.toml`-configurable field in Go + // (`toml:"-"`) — there is no input field for it at all. + const env = buildLegacyStudioEnv(baseEnvInput); + expect(env["LOGFLARE_PRIVATE_ACCESS_TOKEN"]).toBe("api-key"); + }); + + test("falls back OPENAI_API_KEY to an empty string when unset", () => { + const env = buildLegacyStudioEnv({ ...baseEnvInput, openaiApiKey: undefined }); + expect(env["OPENAI_API_KEY"]).toBe(""); + }); + + test("passes through a configured OPENAI_API_KEY", () => { + const env = buildLegacyStudioEnv({ ...baseEnvInput, openaiApiKey: "sk-test" }); + expect(env["OPENAI_API_KEY"]).toBe("sk-test"); + }); + + test('reflects analyticsEnabled/analyticsBackend verbatim (Go\'s fmt.Sprintf("%v", ...))', () => { + const env = buildLegacyStudioEnv({ + ...baseEnvInput, + analyticsEnabled: false, + analyticsBackend: "bigquery", + }); + expect(env["NEXT_PUBLIC_ENABLE_LOGS"]).toBe("false"); + expect(env["NEXT_ANALYTICS_BACKEND_PROVIDER"]).toBe("bigquery"); + }); + + test("EDGE_FUNCTIONS_MANAGEMENT_FOLDER is workdir/supabase/functions in Docker-path form", () => { + const env = buildLegacyStudioEnv({ + ...baseEnvInput, + workdir: "/Users/me/my-project", + }); + expect(env["EDGE_FUNCTIONS_MANAGEMENT_FOLDER"]).toBe("/Users/me/my-project/supabase/functions"); + }); +}); + +describe("buildLegacyStudioContainerSpec", () => { + const baseSpecInput = { + image: "supabase/studio:2026.07.07-sha-a6a04f2", + containerName: "supabase_studio_proj", + networkId: "supabase_network_proj", + port: 54323, + functionBinds: [] as ReadonlyArray, + env: baseEnvInput, + }; + + test("assembles the full container spec, wiring pg-meta's own container name into STUDIO_PG_META_URL", () => { + const spec = buildLegacyStudioContainerSpec(baseSpecInput); + + expect(spec.image).toBe("supabase/studio:2026.07.07-sha-a6a04f2"); + expect(spec.containerName).toBe("supabase_studio_proj"); + expect(spec.networkId).toBe("supabase_network_proj"); + expect(spec.networkAliases).toEqual(["studio"]); + expect(spec.restartPolicy).toBe("unless-stopped"); + expect(spec.labels).toEqual({}); + expect(spec.ports).toEqual([{ hostPort: "54323", containerPort: "3000" }]); + expect(spec.healthcheck).toEqual({ + test: [ + "CMD-SHELL", + `node --eval="fetch('http://127.0.0.1:3000/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`, + ], + intervalSeconds: 10, + timeoutSeconds: 2, + retries: 3, + }); + + // pg-meta URL wiring: a distinct `pgMetaContainerName` (Go's `utils.PgmetaId`, + // resolved by the caller via `legacyServiceContainerName("pg_meta", projectId)`) + // flows through to STUDIO_PG_META_URL exactly like it does in `buildLegacyStudioEnv`. + expect(spec.env["STUDIO_PG_META_URL"]).toBe("http://test-pgmeta:8080"); + }); + + test("derives the snippets bind from env.workdir and includes it alongside functionBinds", () => { + const spec = buildLegacyStudioContainerSpec({ + ...baseSpecInput, + functionBinds: ["/project/supabase/functions/hello:/home/deno/functions/hello:ro"], + }); + + expect(spec.binds).toEqual([ + "/project/supabase/functions/hello:/home/deno/functions/hello:ro", + "/project/supabase/snippets:/project/supabase/snippets:rw", + ]); + // The snippets bind's container-side path also backs SNIPPETS_MANAGEMENT_FOLDER. + expect(spec.env["SNIPPETS_MANAGEMENT_FOLDER"]).toBe("/project/supabase/snippets"); + }); + + test("dedupes the snippets bind against an identical functionBinds entry (Go's utils.RemoveDuplicates)", () => { + const spec = buildLegacyStudioContainerSpec({ + ...baseSpecInput, + functionBinds: ["/project/supabase/snippets:/project/supabase/snippets:rw"], + }); + + expect(spec.binds).toEqual(["/project/supabase/snippets:/project/supabase/snippets:rw"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts new file mode 100644 index 0000000000..bb5631e5bb --- /dev/null +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -0,0 +1,204 @@ +/** + * Port of Go's "Start pooler" block (`apps/cli-go/internal/start/start.go: + * 1193-1268`), gated on `config.db.pooler.enabled` — the gate itself is + * `start.handler.ts`'s job (a later task), not this module's; this file only + * builds the `docker create` spec (plus the pure tenant-provisioning script it + * embeds — see below). + * + * IMPORTANT — how Go actually provisions the Supavisor tenant: it is NOT a + * post-start `docker exec`. Go renders `pooler.exs` (via + * `legacyRenderStartPoolerExs`, already ported in `../lib/template-render.ts`) + * BEFORE the container is created, then bakes the rendered script directly + * into the container's own startup `Cmd` + * (`/bin/sh -c "/app/bin/migrate && /app/bin/supavisor eval '